If your pandas scripts are slow, the culprit is almost always apply and for loops — and the fix is vectorization. This deep-dive lesson explains why the performance gap is so extreme, then gives you a complete hierarchy of fast alternatives including np.where, np.select, and categorical dtypes with real benchmarks throughout.

You've written the script. It runs. But it takes four minutes to process a 200,000-row sales dataset, and you're running it every morning before the 9am standup. Or maybe you're generating a risk score for every customer record, and your apply loop is grinding through rows one at a time while your CPU sits 94% idle. The code is correct, but it's punishingly slow — and you know it shouldn't be.
The root cause is almost always the same: you're asking pandas to work like Python instead of letting it work like pandas. The difference between a row-by-row loop and a vectorized operation isn't a 20% speedup. It's often 10x, 50x, even 200x. That's the gap between a job that runs overnight and one that runs before your coffee gets cold. By the end of this article, you'll understand exactly why that gap exists at a hardware level, and you'll have a concrete, ranked decision framework for replacing slow patterns with fast ones.
What you'll learn:
apply are slow at a fundamental memory and CPU levelnp.where / np.select → apply → loopsapply is actually the right tool and when it's a trapBefore we talk about solutions, let's get precise about the problem. Most tutorials say "loops are slow, use vectorization." That's true, but why is it true? The answer has two parts: Python's object overhead and memory locality.
Every Python integer, string, and float is a full Python object. That object carries a reference count, a pointer to its type, and the actual value — roughly 28 bytes for a simple integer that holds the number 5. When you write a for loop that iterates over a pandas Series, Python has to box each element into one of these objects, process it, and then unbox the result. That boxing/unboxing cycle happens for every single row, and it's expensive.
A pandas Series backed by a NumPy array of 64-bit integers stores those integers as raw, contiguous bytes in memory — 8 bytes each, with no object overhead. When a NumPy operation runs over that array, it's executing a tight C loop over a contiguous block of memory, operating directly on the raw values. No boxing. No interpreter overhead. No reference counting.
Modern CPUs are fast, but memory is relatively slow. CPUs compensate with caches (L1, L2, L3) that hold recently accessed data. When you iterate over a Python list of objects, those objects are scattered across the heap — accessing each one causes a cache miss, forcing the CPU to fetch data from main memory. When NumPy iterates over a contiguous array, each cache line it fetches contains many consecutive values. The CPU prefetcher can predict the access pattern and load the next cache line before it's needed. This is called spatial locality, and it's a massive performance multiplier.
Here's the thing about apply that surprises people: it is almost exactly as slow as a for loop. It's syntactically cleaner, but under the hood, pandas apply iterates over rows or columns in Python, calls your function once per row, and assembles the results. You get all the same object overhead. When you time it, you'll usually see apply is 5–10% faster than an explicit for loop — which is basically the difference between expensive and equally expensive.
Key insight
apply is not a performance optimization — it's a readability optimization over explicit loops. If you're using apply because you think it's fast, you've been misled by its pandas membership.
Let's see the timing difference concretely. Build a realistic dataset: a 500,000-row transaction log with amounts and fee rates.
import pandas as pd
import numpy as np
import time
rng = np.random.default_rng(42)
n = 500_000
df = pd.DataFrame({
"amount": rng.uniform(10, 10_000, n),
"fee_rate": rng.choice([0.01, 0.015, 0.02, 0.025], n),
"category": rng.choice(["retail", "wholesale", "online", "instore"], n),
"days_since_purchase": rng.integers(1, 730, n),
})
Now let's time four different approaches to computing fee = amount * fee_rate:
# Method 1: explicit for loop
start = time.perf_counter()
result_loop = []
for i in range(len(df)):
result_loop.append(df.iloc[i]["amount"] * df.iloc[i]["fee_rate"])
loop_time = time.perf_counter() - start
print(f"Loop: {loop_time:.2f}s")
# Method 2: iterrows
start = time.perf_counter()
result_iterrows = []
for idx, row in df.iterrows():
result_iterrows.append(row["amount"] * row["fee_rate"])
iterrows_time = time.perf_counter() - start
print(f"iterrows: {iterrows_time:.2f}s")
# Method 3: apply
start = time.perf_counter()
result_apply = df.apply(lambda row: row["amount"] * row["fee_rate"], axis=1)
apply_time = time.perf_counter() - start
print(f"apply: {apply_time:.2f}s")
# Method 4: vectorized
start = time.perf_counter()
result_vectorized = df["amount"] * df["fee_rate"]
vectorized_time = time.perf_counter() - start
print(f"Vectorized: {vectorized_time:.4f}s")
Typical results on a modern laptop:
Loop: 42.3s
iterrows: 38.7s
apply: 9.2s
Vectorized: 0.003s
That's not a typo. The vectorized operation is roughly 3,000x faster than the explicit loop and 3,000x faster than apply for this case. Even if your dataset is "only" 50,000 rows, the same ratio holds — the vectorized version finishes before the interpreter can even get started on the loop.
Warning
Never use df.iterrows() for computation. It's useful for inspecting a few rows manually, but it's the slowest possible way to process a DataFrame — slower than apply because it constructs a full Series object for each row. If you find yourself writing for idx, row in df.iterrows(): in production code, stop and reconsider.
Vectorization requires a shift in how you think about data transformations. Instead of asking "what do I do to each row?" you ask "what operation do I apply to each column as a unit?"
This is exactly the mindset SQL uses. When you write SELECT amount * fee_rate AS fee FROM transactions, you're not thinking about rows — you're thinking about column-level operations. Vectorized pandas is the same idea.
Any arithmetic operation between two Series or between a Series and a scalar is automatically vectorized:
# All of these are vectorized — they operate on entire columns at once
df["fee"] = df["amount"] * df["fee_rate"]
df["discounted"] = df["amount"] * 0.9
df["is_high_value"] = df["amount"] > 1000
df["net"] = df["amount"] - df["fee"]
df["log_amount"] = np.log(df["amount"])
df["days_remaining"] = 730 - df["days_since_purchase"]
These feel trivially obvious once you know them, but the trap is the moment you need conditional logic. That's where people reach for apply.
Suppose you need to classify each transaction by size:
# The apply version — slow
df["size_label"] = df["amount"].apply(
lambda x: "large" if x > 5000 else ("medium" if x > 1000 else "small")
)
This works, but it's calling a Python function 500,000 times. The vectorized equivalent uses np.where for a single condition or np.select for multiple conditions:
# np.where for binary conditions — fast
df["is_high_value"] = np.where(df["amount"] > 5000, "large", "other")
# np.select for multiple conditions — still fast
conditions = [
df["amount"] > 5000,
df["amount"] > 1000,
]
choices = ["large", "medium"]
df["size_label"] = np.select(conditions, choices, default="small")
np.where(condition, value_if_true, value_if_false) is evaluated in C over the entire array at once. np.select works the same way, evaluating all conditions in one pass. Let's benchmark this against apply:
# Timing comparison: np.select vs apply for three-way classification
start = time.perf_counter()
_ = df["amount"].apply(lambda x: "large" if x > 5000 else ("medium" if x > 1000 else "small"))
print(f"apply: {time.perf_counter() - start:.3f}s")
start = time.perf_counter()
conditions = [df["amount"] > 5000, df["amount"] > 1000]
choices = ["large", "medium"]
_ = np.select(conditions, choices, default="small")
print(f"np.select: {time.perf_counter() - start:.4f}s")
Typical output:
apply: 0.847s
np.select: 0.006s
Over 100x faster. And the np.select version is arguably more readable, because the conditions and choices are laid out explicitly rather than buried in a nested ternary.
Tip
When you have more than two branches, np.select is almost always the right tool. Build a list of boolean conditions (evaluated left to right, first match wins) and a corresponding list of values. The default parameter handles the "else" case.
If you've been doing string work in pandas — normalizing names, extracting substrings, cleaning messy text — you may have reached for apply because you wanted to use Python string methods:
# The slow way
df["category_upper"] = df["category"].apply(lambda x: x.upper())
df["category_clean"] = df["category"].apply(lambda x: x.strip().lower().replace("-", "_"))
pandas has a vectorized string accessor that exposes almost every Python string method as a Series-level operation:
# The fast way
df["category_upper"] = df["category"].str.upper()
df["category_clean"] = df["category"].str.strip().str.lower().str.replace("-", "_", regex=False)
The .str accessor works over the entire Series using a C-level implementation of the string operations. For large text columns, the speedup is significant — typically 5–20x over apply.
The .str accessor supports a rich set of operations that cover the vast majority of text cleanup tasks. For a comprehensive look at what's available — including regex support, extraction, and splitting — see Text Cleanup at Scale with pandas String Methods and Regular Expressions.
# Practical string operations — all vectorized
df["has_online"] = df["category"].str.contains("online", case=False)
df["first_char"] = df["category"].str[0]
df["length"] = df["category"].str.len()
df["padded"] = df["category"].str.pad(width=15, fillchar=" ")
# Regex extraction — e.g., extracting year from a date string column
df["notes"] = ["Order #2024-001", "Order #2023-788", "Order #2024-442"] * (n // 3) + ["Order #2024-001"] * (n % 3)
df["year"] = df["notes"].str.extract(r"(\d{4})", expand=False)
Warning
The .str accessor only works efficiently on columns with object or StringDtype dtype. If your string column contains mixed types (strings mixed with NaN mixed with integers), operations will be slower and less predictable. Always check df.dtypes and clean your columns before doing string work at scale — see Cleaning Messy Data with pandas: Missing Values, Duplicates, and Data Types for dtype normalization strategies.
Datetime columns have their own vectorized accessor, .dt, which exposes calendar properties and time calculations as Series-level operations:
df["purchase_date"] = pd.Timestamp("2024-01-01") - pd.to_timedelta(df["days_since_purchase"], unit="D")
# All vectorized
df["purchase_year"] = df["purchase_date"].dt.year
df["purchase_month"] = df["purchase_date"].dt.month
df["purchase_dayofweek"] = df["purchase_date"].dt.dayofweek # 0=Monday
df["is_weekend"] = df["purchase_date"].dt.dayofweek >= 5
df["quarter"] = df["purchase_date"].dt.quarter
df["days_to_yearend"] = (pd.Timestamp("2024-12-31") - df["purchase_date"]).dt.days
The .dt accessor is to datetimes what .str is to strings: a vectorized interface that eliminates the need for apply. For the full picture of working with time series data, including resampling and rolling windows, see Working with Dates and Time Series in pandas: Parsing, Resampling, and Rolling Windows.
Now that you've seen the tools, let's make this concrete as a decision hierarchy. When you need to compute a new column or transform data, ask these questions in order:
Tier 1 — Pure vectorized operations (fastest)
Built-in pandas arithmetic, comparison, and aggregation on numeric or datetime columns. Uses compiled C/Fortran code. This is always your first choice.
df["fee"] = df["amount"] * df["fee_rate"]
df["above_avg"] = df["amount"] > df["amount"].mean()
Tier 2 — NumPy ufuncs and functions (fast)
NumPy functions that operate on arrays:
np.where,np.select,np.log,np.clip,np.abs. These operate at the same C level as pandas internals.
df["size_label"] = np.select(conditions, choices, default="small")
df["capped_amount"] = np.clip(df["amount"], 0, 9999)
Tier 3 — pandas .str and .dt accessors (fast for their types)
Vectorized string and datetime operations. Much faster than apply for text/time columns.
df["clean_cat"] = df["category"].str.strip().str.lower()
df["month"] = df["purchase_date"].dt.month
Tier 4 — map with a dictionary or function (moderate)
For discrete-value lookups (replacing values in a categorical column),
Series.map()with a dictionary is vectorized and fast. For complex functions, it's still a Python-level loop.
# Fast: dictionary map is vectorized lookup
tier_map = {"retail": "B2C", "wholesale": "B2B", "online": "B2C", "instore": "B2C"}
df["tier"] = df["category"].map(tier_map)
Tier 5 — apply with a simple function (slow)
When none of the above can express your logic. Reserve for genuinely complex, non-vectorizable operations.
Tier 6 — explicit for loops (never in production)
No justification for computation over large DataFrames. Reserved for prototyping or operating on a small number of rows.
Key insight
Moving from Tier 5 to Tier 1 on a 500,000-row dataset is not a 2x speedup — it's often 100x to 3,000x. Even moving from Tier 5 to Tier 2 is worth engineering effort on any dataset you process repeatedly.
Let's be fair to apply. There are genuinely situations where it's the appropriate tool — not as a fallback after failing to vectorize, but as the right choice by design.
Some operations involve Python-level state, branching based on multiple columns in complex ways, or external function calls that have no vectorized equivalent:
import json
# Parsing a JSON string column — no vectorized alternative
df["metadata"] = ['{"score": 4.5, "tags": ["A", "B"]}', '{"score": 3.1, "tags": ["C"]}'] * (n // 2)
df["score"] = df["metadata"].apply(lambda x: json.loads(x).get("score", None))
Here, apply is correct. You're calling a Python function that has no NumPy equivalent. However, if performance matters, consider parsing the column once with a list comprehension — which avoids the pandas overhead of apply:
# List comprehension is often faster than apply for pure Python operations
df["score"] = [json.loads(x).get("score", None) for x in df["metadata"]]
Sometimes you need to compute something from several columns in a way that's hard to express as column arithmetic:
# Business rule: fee is 2% unless amount > 5000 AND category is 'wholesale',
# in which case it's 1.5%, or if days_since_purchase < 30 AND amount < 500,
# in which case there's no fee
def compute_fee(row):
if row["days_since_purchase"] < 30 and row["amount"] < 500:
return 0.0
elif row["amount"] > 5000 and row["category"] == "wholesale":
return row["amount"] * 0.015
else:
return row["amount"] * 0.02
df["fee"] = df.apply(compute_fee, axis=1)
This can be rewritten with np.select, and you should do so if this runs frequently:
# Vectorized equivalent — faster by ~100x
cond_no_fee = (df["days_since_purchase"] < 30) & (df["amount"] < 500)
cond_wholesale = (df["amount"] > 5000) & (df["category"] == "wholesale")
df["fee"] = np.select(
[cond_no_fee, cond_wholesale],
[0.0, df["amount"] * 0.015],
default=df["amount"] * 0.02
)
Tip
Even when apply seems necessary for multi-column conditional logic, try to express each condition as a boolean Series first. If you can build a list of boolean Series and a list of result expressions, np.select can almost always handle it.
groupby().apply() is a different beast from column-wise apply. When you need to apply a function to each group that itself returns a DataFrame or complex structure, groupby().apply() is the right tool:
# This can't easily be expressed with standard groupby aggregations
def top_n_by_amount(group_df, n=3):
return group_df.nlargest(n, "amount")
top_transactions = df.groupby("category").apply(top_n_by_amount, n=3)
For standard aggregations (sum, mean, count, std), always use groupby().agg() — it's vectorized and dramatically faster. See Grouping and Aggregating in pandas: groupby as the PivotTable Replacement for the full range of vectorized groupby patterns.
Before you optimize anything, profile. The fastest code improvements go to the hottest code paths — not the ones that look expensive.
# In Jupyter, %timeit runs the expression many times and reports the average
%timeit df["amount"] * df["fee_rate"]
%timeit df["amount"].apply(lambda x: x * 0.02)
import time
start = time.perf_counter()
result = df["amount"] * df["fee_rate"]
elapsed = time.perf_counter() - start
print(f"Elapsed: {elapsed*1000:.2f}ms")
For larger scripts with many operations, cProfile reveals where time is actually being spent:
import cProfile
def process_dataframe(df):
df["fee"] = df["amount"] * df["fee_rate"]
df["size_label"] = np.select(
[df["amount"] > 5000, df["amount"] > 1000],
["large", "medium"],
default="small"
)
df["category_clean"] = df["category"].str.strip().str.lower()
return df
cProfile.run("process_dataframe(df)", sort="cumulative")
Tip
Profile with realistic data sizes. A function that's fast on 1,000 rows may be the bottleneck on 1,000,000 rows. Always profile with the actual data volume you expect in production.
Sometimes performance problems are memory problems. When you're processing large DataFrames, excessive memory usage causes the OS to swap, which is catastrophic for performance:
# pip install memory_profiler
from memory_profiler import memory_usage
mem_usage = memory_usage((process_dataframe, (df,)), interval=0.1)
print(f"Peak memory: {max(mem_usage):.1f} MB")
The biggest performance wins are often not in replacing apply with vectorization — they're in fixing your data types before you do any computation at all.
The object dtype is pandas' catch-all for anything it can't represent as a fixed-size C type. This includes strings, mixed types, and Python objects. An object column is essentially a NumPy array of Python object pointers — all the cache-miss problems of a Python list, baked into your DataFrame.
print(df.dtypes)
If you see object next to a column that should be a string, that's expected. But if you see object where you expected float64, you have mixed types — probably NaN mixed with non-numeric values.
# Check memory usage by dtype
df.memory_usage(deep=True)
The deep=True argument traverses object columns to report their actual memory consumption, not just the pointer size.
The category dtype is one of the most powerful performance tools in pandas, and it's underused. If you have a string column with low cardinality (few unique values relative to the number of rows), converting to category can reduce memory by 90%+ and make operations significantly faster:
# Our 'category' column has 4 unique values over 500,000 rows
print(df["category"].dtype) # object
print(df["category"].nunique()) # 4
df["category"] = df["category"].astype("category")
print(df["category"].dtype) # category
print(df.memory_usage(deep=True)["category"]) # much smaller
Internally, pandas stores a category column as a small integer array (the category codes) plus a dictionary mapping codes to values. Operations like groupby, value_counts, and filtering work directly on the integer codes — dramatically faster than string comparison.
# Benchmark: filtering on object vs category column
df["category_obj"] = df["category"].astype(str)
%timeit df[df["category_obj"] == "wholesale"]
%timeit df[df["category"] == "wholesale"]
Typical result: the category version is 3–8x faster for filtering on large DataFrames.
Key insight
Converting a low-cardinality string column to category is one of the highest-ROI optimizations available. Do it early in your pipeline, before any groupby, filter, or merge on that column.
If your amounts are always in the range 0–10,000 and don't need more than 2 decimal places, you don't need float64 (8 bytes per value). float32 (4 bytes) uses half the memory and is faster to compute on:
# Before
print(df["amount"].dtype) # float64
print(df["amount"].nbytes) # 4,000,000 bytes
# After
df["amount_f32"] = df["amount"].astype(np.float32)
print(df["amount_f32"].nbytes) # 2,000,000 bytes
Be careful with precision: float32 has about 7 significant decimal digits, which is sufficient for money amounts up to ~$10M with cent precision. For financial calculations where precision matters critically, keep float64.
For integer columns, pd.to_numeric(col, downcast="integer") will automatically find the smallest integer type that fits your data:
df["days_since_purchase"] = pd.to_numeric(df["days_since_purchase"], downcast="integer")
print(df["days_since_purchase"].dtype) # int16 if max < 32,767
One thing that makes people reach for apply is NaN handling. They write:
df["result"] = df["amount"].apply(lambda x: some_function(x) if pd.notna(x) else np.nan)
But NumPy operations already propagate NaN automatically for arithmetic:
# NaN propagates naturally — no apply needed
df["fee"] = df["amount"] * df["fee_rate"] # NaN where amount is NaN
For np.where and np.select, you need to handle NaN explicitly in your conditions:
# If amount can be NaN, the condition df["amount"] > 5000 returns False for NaN
# which is usually the behavior you want — NaN rows fall to the default
df["size_label"] = np.select(
[df["amount"].notna() & (df["amount"] > 5000),
df["amount"].notna() & (df["amount"] > 1000)],
["large", "medium"],
default="small" # NaN rows will get "small" — consider whether that's correct
)
# To explicitly preserve NaN:
df["size_label"] = np.select(
[df["amount"].isna(),
df["amount"] > 5000,
df["amount"] > 1000],
[np.nan, "large", "medium"],
default="small"
)
For string operations, .str methods return NaN where the input is NaN, which is almost always the right behavior:
df["category_clean"] = df["category"].str.strip().str.lower() # NaN stays NaN
For the cases where you genuinely can't vectorize — recursive algorithms, simulation models, complex custom aggregations — there are tools that let you write Python-like code that executes at C speed.
Numba compiles Python functions to LLVM machine code at runtime. It's remarkably effective for numerical loops that can't be expressed as vectorized operations:
from numba import jit
import numpy as np
@jit(nopython=True)
def compute_custom_score(amounts, rates, days):
"""A deliberately complex score that resists simple vectorization."""
n = len(amounts)
scores = np.empty(n)
running_avg = 0.0
for i in range(n):
running_avg = running_avg * 0.95 + amounts[i] * 0.05
if days[i] < 30:
scores[i] = amounts[i] * rates[i] * 1.5
elif amounts[i] > running_avg * 2:
scores[i] = amounts[i] * rates[i] * 0.8
else:
scores[i] = amounts[i] * rates[i]
return scores
# Pass NumPy arrays directly
scores = compute_custom_score(
df["amount"].values,
df["fee_rate"].values,
df["days_since_purchase"].values
)
df["custom_score"] = scores
The first call is slower (compilation overhead), but subsequent calls are nearly as fast as hand-written C. This is ideal for complex scoring functions that run on every pipeline execution.
Note
Numba works best with numeric arrays and basic control flow. It doesn't support arbitrary Python objects or pandas DataFrames directly — always pass .values (a NumPy array) to Numba functions.
When your dataset doesn't fit in RAM, or when you want to parallelize across CPU cores, Dask provides a pandas-compatible API that partitions your data and processes chunks in parallel:
import dask.dataframe as dd
# Read directly into Dask — lazy evaluation
ddf = dd.from_pandas(df, npartitions=4) # or dd.read_csv("large_file.csv")
# Same syntax as pandas — executed in parallel across partitions
result = ddf["amount"] * ddf["fee_rate"]
# Compute triggers actual execution
result_pandas = result.compute()
Dask doesn't make operations faster per se — the same vectorization principles apply. But it lets you run those operations on 10GB files by processing them in chunks, and it can distribute work across multiple cores or machines.
You're working with a retail analytics platform. You have a DataFrame with 300,000 transaction records. Here's the setup:
import pandas as pd
import numpy as np
rng = np.random.default_rng(99)
n = 300_000
transactions = pd.DataFrame({
"transaction_id": range(n),
"customer_id": rng.integers(1000, 9999, n),
"store_id": rng.choice(["S001", "S002", "S003", "S004", "S005"], n),
"product_category": rng.choice(["Electronics", "Clothing", "Food", "Hardware", "Books"], n),
"gross_amount": rng.uniform(5, 2000, n),
"return_flag": rng.choice([0, 1], n, p=[0.92, 0.08]),
"loyalty_tier": rng.choice(["Bronze", "Silver", "Gold", "Platinum"], n, p=[0.4, 0.3, 0.2, 0.1]),
"transaction_date": pd.date_range("2023-01-01", periods=n, freq="2min"),
})
Your tasks — all must be completed using vectorized operations (no apply, no loops):
Discount calculation: Add a discount_rate column. Rules: Platinum customers get 15% off, Gold get 10%, Silver get 5%, Bronze get 2%. Use np.select.
Net amount: Calculate net_amount = gross_amount * (1 - discount_rate). For transactions with return_flag == 1, set net_amount to negative.
Revenue category: Label transactions as "High" (net_amount > 500), "Medium" (100–500), or "Low" (under 100).
Date features: Extract month, day_of_week, and a boolean is_weekend column from transaction_date.
Store-tier label: Create a store_tier column by combining store_id and loyalty_tier into a string like "S001_Gold". Use vectorized string concatenation.
Dtype optimization: Convert store_id, product_category, and loyalty_tier to category dtype. Report the before-and-after memory usage.
Bonus: Profile each of your operations with %timeit and compare operation #1 against an apply equivalent.
Expected outcome: All 7 operations should complete in under 2 seconds total on a modern laptop.
This is the most common rationalization for reaching for apply. Before you accept that conclusion, ask: Can I express each branch as a boolean condition on the column? If yes, np.select can handle it. The rule of thumb: if you can write the condition as a Python if statement that references row["column_name"], you can usually rewrite it as df["column_name"] <op> value — which is a boolean Series — and feed it to np.select.
It may feel fast on your dev machine with a 10,000-row test dataset. But the scale is roughly linear: if apply takes 0.3 seconds on 10,000 rows, it will take 90 seconds on 3,000,000 rows. Vectorization doesn't scale the same way — it often stays under 1 second even at that size. Build the habit now, before the dataset size bites you.
This warning appears when pandas suspects you're modifying a view rather than a copy. It often appears when you're chaining operations:
# Potential issue — may trigger SettingWithCopyWarning
subset = df[df["amount"] > 1000]
subset["fee"] = subset["amount"] * 0.02 # Warning here
# Fix: use .copy() explicitly
subset = df[df["amount"] > 1000].copy()
subset["fee"] = subset["amount"] * 0.02 # Clean
For a deep dive on filtering and selection patterns, see Selecting and Filtering Data in pandas: loc, iloc, and Boolean Masks.
np.select returns the value for the first condition that matches. If your conditions overlap, order matters:
# BUG: if amount is 6000, both conditions are True.
# Which label does it get?
conditions = [
df["amount"] > 1000, # This matches 6000 — so it returns "medium"!
df["amount"] > 5000, # Never reached for 6000
]
choices = ["medium", "large"]
# FIX: put the most specific condition first
conditions = [
df["amount"] > 5000, # More specific — checked first
df["amount"] > 1000,
]
choices = ["large", "medium"]
If you've replaced apply with vectorized operations but performance is still disappointing, check:
object columns? String comparisons on object dtype are slower than on category. Check df.dtypes.(df["amount"] * df["fee_rate"]).clip(0, 999) instead of creating a fee column and then a capped_fee column.df.copy() on a 500MB DataFrame are expensive. Profile memory alongside time.Warning
Premature optimization is a real risk. Profile first, then optimize. It's easy to spend an hour optimizing a function that accounts for 5% of your pipeline's runtime while the 95% bottleneck (a slow file read, a full-table scan) goes unaddressed.
Here's what we covered and why each piece matters:
apply are slow: Python object overhead, interpreter bytecode execution, and cache-miss-heavy memory access patterns — versus NumPy's C-level operations on contiguous memory.np.where and np.select: The vectorized alternative to if/else logic in apply. They evaluate conditions over entire arrays and are typically 50–200x faster..str and .dt accessors: Vectorized interfaces for string and datetime operations, eliminating the most common reasons to reach for apply..str/.dt → dictionary map → apply → loops. Work from the top down.category reduces memory by 80–95% and speeds up groupby and filter operations significantly.%timeit, cProfile, and memory_profiler to find real bottlenecks rather than optimizing by intuition.@jit(nopython=True) gives you C-level speed with Python syntax.The techniques in this article compound with everything else in the pandas ecosystem. When you're building automated reports with these optimized DataFrames, see Automating Excel Reports with pandas and openpyxl: Formatted Workbooks Without Manual Work for how to output results efficiently. When your pipeline involves merging multiple DataFrames, the join performance will also benefit from the categorical dtype techniques covered here — see Joining DataFrames with pandas merge: SQL Joins and VLOOKUP in Python for merge patterns.
The ultimate goal isn't to memorize which function is fastest — it's to build the habit of reaching for column-level operations first, every time, and only falling back to apply when you've genuinely exhausted the vectorized options. That habit, consistently applied, is the difference between pandas code that scales and pandas code that doesn't.