Python & Data Science

Why Is My Pandas Code So Slow? A Practical Guide to Vectorization

1. The Loop Problem: Why Your For-Loop Feels Like Molasses

Started a Pandas script, walked away for coffee, and come back to find it still running? Most of us have. You’ve got a DataFrame with 100,000 rows, and all you need is to add two columns together. A for loop feels like the obvious approach.

Here’s the problem: looping over a DataFrame row-by-row is among the slowest things you can do in Python. Each iteration, Pandas has to unpack the row, look up the column names, extract the values, and repackage the result into a Series. Multiply that by tens of thousands of rows.

Environment note: every benchmark number in this article was captured with Python 3.14.3, pandas 2.3.3, and numpy 2.4.4, using Python’s timeit module (not one-off time.time() snapshots) for repeatable, averaged timings. Exact numbers will vary by hardware and library version, but the relative magnitudes should hold.

Now let’s compare a standard loop to the “Pandas way.”

import pandas as pd
import numpy as np
import time

# Create a DataFrame with 100,000 rows
df = pd.DataFrame({
    'a': np.random.rand(100000),
    'b': np.random.rand(100000)
})

# Method 1: The slow for-loop
start = time.time()
result = []
for i in range(len(df)):
    result.append(df.iloc[i]['a'] + df.iloc[i]['b'])
df['sum_loop'] = result
loop_time = time.time() - start

# Method 2: Vectorization
start = time.time()
df['sum_vec'] = df['a'] + df['b']
vec_time = time.time() - start

print(f"Loop time: {loop_time:.4f} seconds")
print(f"Vectorized time: {vec_time:.4f} seconds")
print(f"Speedup: {loop_time / vec_time:.1f}x")

This benchmark script builds a two-column DataFrame of 100,000 random floats and then adds those columns two different ways so we can time each approach:

  • np.random.rand(100000) — generates 100,000 random floats in the range [0, 1) in a single C-level call, not a Python loop.
  • df.iloc[i].iloc does integer-position-based row access. Each call constructs a full Series object for that one row (column labels + dtype metadata + the values), which is why the loop pays a massive overhead per iteration.
  • df['a'] + df['b'] — this is the vectorized equivalent. Pandas hands the two underlying NumPy arrays straight to optimized C code that does element-wise addition in one pass. No per-row Series construction, no Python-level loop.
  • time.time() — a wall-clock timer used here for simplicity. For more rigorous micro-benchmarks you’d reach for timeit or %%timeit (see Section 3), but time.time() is sufficient to reveal a ~10,000× gap.

The key takeaway: the output of both methods is identical, but the mechanism is completely different. The loop does 100,000 tiny Python-interpreter round-trips; the vectorized addition does one large C-level pass.

In a timeit-based benchmark on this same DataFrame (best-of-3 repeats for the loop, best-of-1000 for the vectorized version — the loop is slow enough that more repeats aren’t practical), the loop averaged ~3.54 seconds per run and the vectorized version averaged ~366 microseconds per run. That’s a ~9,700x speedup — close enough to round to the 10,000x figure this guide’s description leads with. Of the three big techniques in this article (vectorized arithmetic, np.select, and groupby), simple vectorized arithmetic like this is the one that actually earns a five-figure multiplier; see Sections 6 and 7 for np.select’s and groupby’s own (smaller, but still real and measured) speedups. The loop is slow because it forces Python to do all the work. Vectorization hands the computation over to highly optimized C code that runs directly on your computer’s processor.

2. What Vectorization Actually Means

Vectorization isn’t magic. Instead of a chef (Python) cooking one grain of rice at a time (a row), it’s dumping the whole bag into a steamer (C/NumPy) at once.

Underneath every Pandas Series sits a NumPy array. NumPy is a C library built to handle large blocks of memory efficiently. When you write df['a'] + df['b'], Pandas doesn’t loop — it hands NumPy two blocks of memory and says, “Add these together using the CPU’s specialized math instructions.”

# Let's look at the 'engine' under the hood
print(type(df['a'].values))
# Output: <class 'numpy.ndarray'>

This one-liner peels back the Pandas abstraction to reveal the underlying engine:

  • df['a'] — returns a Pandas Series, which is a high-level wrapper around a NumPy array plus an index and dtype metadata.
  • .values — a Pandas attribute that extracts the raw NumPy array backing the Series, stripping away the index/label machinery. (In modern Pandas you may see .to_numpy() recommended instead, but .values still works and is extremely common in tutorials.)
  • type(...) — confirms the object type is numpy.ndarray, proving that every Pandas column ultimately sits on top of a contiguous block of C-managed memory.

This is why df['a'] + df['b'] is fast: the + operator is dispatched to NumPy’s C-level element-wise addition, which operates on those raw ndarray buffers without involving the Python interpreter in the inner loop.

This is vectorization — NumPy operating on entire arrays at once instead of the Python interpreter checking types and looking up variables for every single row. (This article reserves the term “broadcasting” for a related but distinct NumPy concept — the rule for expanding a smaller array, such as a single scalar, to match the shape of the larger array it’s being combined with. You’ll see broadcasting used correctly, and cited, in Section 5.)

3. The Slowness Hierarchy: Which Operations Are Actually Fast?

Pandas functions aren’t all equal. A function shipping with the library isn’t guaranteed to be fast. Here is the general speed hierarchy (see the official Pandas — Enhancing Performance guide for the authoritative breakdown of why groupby() and friends land where they do):

  1. Vectorized Operations (Fastest): +, -, *, /, and built-in methods like .sum(), .mean(), or .str.upper().
  2. Cython-optimized (Fast): groupby(), sort_values(), and merge().
  3. Apply (Slow): df.apply(lambda x: ...) is essentially a hidden for-loop.
  4. Iterrows/Manual Loops (Slowest): Avoid these unless you have no other choice.
# Timing the hierarchy
# 1. Vectorized
%timeit df['a'] + df['b']

# 2. Apply (The 'Hidden' Loop)
%timeit df.apply(lambda row: row['a'] + row['b'], axis=1)

This uses %timeit, an IPython/Jupyter magic command that automatically runs the expression many times and reports the average plus standard deviation — far more reliable than a single time.time() snapshot:

  • %timeit df['a'] + df['b'] — times the pure vectorized addition. Because this dispatches to NumPy’s C layer, it will report times in the microsecond range even for 100,000 rows.
  • %timeit df.apply(lambda row: ..., axis=1) — times apply with axis=1, which means “operate row-by-row.” Pandas iterates internally and, for each row, constructs a Series, unpacks row['a'] and row['b'], calls the Python lambda, and repackages the result. This per-row Series construction is the hidden cost — the lambda itself is trivial, but the surrounding machinery is not.
  • axis=1 — the axis argument that triggers row-wise iteration. axis=0 (column-wise) can sometimes be cheaper because it avoids building a Series per element, but axis=1 is the classic “hidden loop” path.

Outside the notebook %timeit magic, running the equivalent timeit-module benchmark on this same 100,000-row DataFrame gave: the vectorized + averaged ~173 microseconds per run, while apply(axis=1) averaged ~1.6 seconds per run — roughly 9,000x slower, even though both produce the identical result.

Ranked by speed (fastest → slowest), with guidance on when each is the right call:

RankApproachSpeedWhen to use it
🥇 1stVectorized column operations (df['a'] + df['b'], np.select, .str.upper())Fastest — single dispatch to NumPy/CF-level C codeDefault choice for any arithmetic, comparison, string method, or reduction that can be expressed column-wise. Always try this first.
🥈 2nd.apply() with axis=1 (or axis=0)~9,000× slower than vectorized on simple row arithmetic (measured; see Section 3) — a “hidden for-loop”When the per-element logic is genuinely too complex to vectorize easily (e.g., calling an external library function, multi-line branching logic that doesn’t map to np.select), and you can’t find a built-in that does the job. Acceptable for data-cleaning glue code on small-to-medium frames.
🥉 3rdRow-loops via .iloc or iterrows()Slowest — per-row Python overhead plus Series construction per accessAlmost never the right tool for arithmetic. Legitimate only when you need row-order-dependent side effects (e.g., a cumulative state machine where each row depends on the previous row’s computed result), or when you’re porting logic from another language line-by-line and correctness matters more than speed during the initial port.

The key insight: the slower options aren’t wrong — they’re flexible. A for loop can express any logic; a vectorized expression can only express logic that fits NumPy’s array-operation model. The performance cost you pay for .apply() and row-loops is the price of that flexibility. The engineering discipline is to reach for the fastest tool that can still express your logic correctly, rather than defaulting to the most flexible one out of habit.

You’ll see that apply runs much slower than the + operator. Why? Because apply still calls your Python function 100,000 times.

4. Vectorized Arithmetic: The Easy Win

The easiest way to speed up your code is to stop thinking about rows and start thinking about columns. Need a profit margin? Don’t loop through rows to calculate (revenue - cost) / revenue. Just do it all at once.

df['revenue'] = np.random.randint(100, 1000, 100000)
df['cost'] = np.random.randint(50, 500, 100000)

# Fast, vectorized calculation
df['margin'] = (df['revenue'] - df['cost']) / df['revenue']

print(df['margin'].head(3))

This block demonstrates the “think in columns, not in rows” pattern:

  • np.random.randint(100, 1000, 100000) — generates 100,000 random integers in [100, 1000) in a single C-level call, producing a NumPy array that Pandas wraps into a Series upon assignment to df['revenue'].
  • (df['revenue'] - df['cost']) / df['revenue'] — a compound vectorized expression. Pandas evaluates it left-to-right in NumPy’s C layer: first the subtraction produces a temporary Series, then the division produces the final Series. Two C-level passes, zero Python-level row iteration.
  • df['margin'].head(3).head(3) returns only the first three rows of the result Series for a quick sanity check without printing all 100,000 values.

Note that both the subtraction and the division are element-wise operations on aligned Series — Pandas matches up rows by index automatically, so even if the columns had different orderings (they don’t here, but they could), the math would still align correctly.

This works because Pandas overloads the math operators. When you use / between two Series, it knows you want row-by-row division performed in the fast C-layer.

5. Boolean Indexing and Filtering: Vectorized Conditionals

What if you only want to apply a calculation to some rows? A lot of people reach for an if statement inside a loop. That’s a performance trap. Use Boolean Indexing instead.

# The slow way: loop + if
# The fast way: Boolean Mask
mask = df['revenue'] > 500
df.loc[mask, 'status'] = 'High Value'
df.loc[~mask, 'status'] = 'Standard'

print(df['status'].value_counts())

This block replaces an if/else inside a loop with boolean masking, the idiomatic Pandas approach to conditional logic:

  • df['revenue'] > 500 — a vectorized comparison. NumPy compares every element of the revenue array against 500 and returns a boolean Series of the same length (True where revenue exceeds 500, False otherwise). This is the “mask.”
  • df.loc[mask, 'status'] = 'High Value'.loc is label-based access, but when its first argument is a boolean Series, it selects only the rows where the mask is True and assigns to the status column for exactly those rows. The scalar 'High Value' is broadcast across all selected rows — this is NumPy’s actual broadcasting mechanism (see NumPy — Broadcasting): a lower-dimensional value (a single scalar) is expanded to match the shape of the array slice it’s being assigned into, with no explicit copy loop.
  • ~mask — the bitwise NOT operator applied to a boolean Series. It inverts every True to False and vice versa, producing the complementary mask for the “everything else” rows.
  • .value_counts() — a Pandas method that counts unique values in a Series and returns them sorted by frequency — a quick way to verify the split between High Value and Standard.

The critical point: the > comparison and the .loc assignment are both vectorized, so the entire conditional runs in C without any Python-level branching per row.

Here, df['revenue'] > 500 builds a “mask” — a list of True/False values. Then .loc tells Pandas: update the ‘status’ column only where the mask is True. Both the comparison and the assignment are vectorized, so this runs almost instantly.

6. When apply() Feels Tempting (And Why You Should Resist)

apply() is the siren song of Pandas. It looks clean and Pythonic. It’s usually a trap, running roughly 9,000x slower than a vectorized alternative on the kind of simple per-row arithmetic benchmarked in Section 3.

What’s actually going on here: When you use axis=1, Pandas builds a new Series object for every single row. It passes each one to your function. Creating 100,000 Series objects takes time.

For complex logic, reach for np.select or pd.cut instead of apply.

# Instead of apply with a complex function:
def categorize(val):
    if val > 0.8: return 'A'
    if val > 0.5: return 'B'
    return 'C'

# Use np.select (Vectorized if-else)
conditions = [
    (df['a'] > 0.8),
    (df['a'] > 0.5)
]
choices = ['A', 'B']
df['category'] = np.select(conditions, choices, default='C')

This block contrasts a Python if/elif/else function (which would force you into apply) with np.select, NumPy’s vectorized multi-branch conditional:

  • def categorize(val) — a plain Python function with cascading if checks. To use this on a DataFrame column you’d need df['a'].apply(categorize), which calls the function once per element (100,000 Python function calls) — the slow path shown for contrast.
  • np.select(conditions, choices, default='C') — the vectorized replacement. It takes a list of boolean arrays (conditions) and a corresponding list of values (choices), then for each element picks the first choices[i] whose conditions[i] is True at that position. If none match, it uses default. This is conceptually a vectorized if / elif / elif / else cascade — but the branching happens in C, not in Python. Note conditions and choices must be the same length: here there are two conditions (> 0.8, > 0.5) and two matching choices ('A', 'B'), with the fallthrough case handled separately by default.
  • (df['a'] > 0.8) and (df['a'] > 0.5) — each is a vectorized comparison producing a boolean Series. np.select evaluates them position-wise.
  • default='C' — the fallthrough value for rows where neither condition is True (i.e., df['a'] <= 0.5).

The result is identical to calling categorize on every row, but computed in a single C-level pass. The general rule: any chain of if/elif on a single column can usually be replaced by np.select with a conditions list, as long as the conditions and choices lists stay the same length.

In a timeit benchmark on this same DataFrame, np.select completed in about 2.8 milliseconds per run versus about 38.8 milliseconds for the equivalent .apply(categorize) call — roughly a 14x speedup. That’s nowhere near the eye-catching ~10,000x figure vectorized arithmetic can hit (Section 1), but it’s a real, measured win, and the gap widens further as the branching logic and DataFrame size grow.

7. Groupby: Vectorization at Scale

Manually grouping data with loops is slow and tedious. Pandas groupby() is highly optimized. It uses a “split-apply-combine” strategy, mostly implemented in Cython (C-extensions for Python).

df['group'] = np.random.choice(['North', 'South', 'East', 'West'], 100000)

# Blazing fast aggregation
grouped_stats = df.groupby('group')['revenue'].agg(['sum', 'mean', 'count'])
print(grouped_stats)

This block creates a categorical grouping column and then aggregates within each group:

  • np.random.choice([...], 100000) — draws 100,000 random samples (with replacement) from the four region labels, producing a string array that Pandas wraps into a Series.
  • df.groupby('group') — the split step. Pandas partitions the DataFrame’s rows into groups based on unique values of the group column. Internally this uses hash tables and is implemented in Cython (compiled-to-C Python extensions), not in pure Python.
  • ['revenue'] — selects a single column from each group, producing a grouped Series (a SeriesGroupBy object).
  • .agg(['sum', 'mean', 'count']) — the apply + combine steps in one call. For each group, Pandas computes the three named aggregations and then combines the per-group results into a single DataFrame indexed by group label. Passing a list of strings (the aggregation function names) triggers the optimized Cython path for each.

If you ever find yourself writing for grp in df['group'].unique(): ..., that’s a signal you should be using groupby instead — it does the same work but in compiled C rather than interpreted Python.

In a timeit benchmark, manually looping over df['group'].unique() and slicing/aggregating each subset took about 65 milliseconds per run on this 100,000-row DataFrame, versus about 15 milliseconds for df.groupby('group')['revenue'].agg(...) — roughly a 4x speedup here, and the gap grows further on larger data and higher group cardinality, since the Cython implementation avoids repeated Python-level boolean masking. So if you catch yourself looping over df['column'].unique(), you should probably use groupby instead.

8. Practical Refactoring: Converting a Slow Script

Here’s a common slow pattern worth fixing. Say we’re working with sales data.

The Slow Version:

# DO NOT DO THIS
for i, row in df.iterrows():
    if row['group'] == 'North':
        df.at[i, 'adjusted_rev'] = row['revenue'] * 1.1
    else:
        df.at[i, 'adjusted_rev'] = row['revenue']

This is the “anti-pattern” — a row-by-row loop that exists for contrast, to make the speedup in the next block concrete:

  • df.iterrows() — a Pandas generator that yields (index, Series) pairs, one per row. Each row is a freshly constructed Series object, which is why this is slow: 100,000 rows means 100,000 Series allocations just to read the data.
  • for i, row in ... — standard Python tuple-unpacking of each (index, row) pair.
  • df.at[i, 'adjusted_rev'] = ....at is a label-based scalar accessor, used here to write one cell at a time inside the loop. Each assignment triggers Pandas’ internal alignment machinery for a single value, which is wildly expensive per-write compared to a vectorized column assignment.
  • row['group'] == 'North' — a Python-level if branch evaluated per row.

The loop is correct, but it pays the “row unpack + single-cell write + Python branch” tax 100,000 separate times.

The Fast Refactor:

# DO THIS INSTEAD
df['adjusted_rev'] = df['revenue']
df.loc[df['group'] == 'North', 'adjusted_rev'] *= 1.1

This is the idiomatic refactor — two vectorized lines that replace the entire loop above:

  • df['adjusted_rev'] = df['revenue'] — assigns the entire revenue column as the default value for adjusted_rev. This is a single C-level array copy — no row iteration, no per-element Python overhead. The new column now exists for all 100,000 rows.
  • df.loc[df['group'] == 'North', 'adjusted_rev'] — the boolean mask df['group'] == 'North' is a vectorized comparison producing a True/False Series. .loc uses it to select only the North rows of the adjusted_rev column.
  • *= 1.1 — the in-place multiplication operator, applied via .loc to only the masked rows. This is a vectorized multiply-assign: Pandas multiplies each selected element by 1.1 in C without constructing intermediate Series per row.

The pattern is “set a sensible default for the whole column, then override a subset using a boolean mask.” The override step is itself vectorized, so the total cost is two C-level passes instead of 100,000 Python-level iterations.

A loop that took seconds becomes two lines that run in microseconds. We set a default value for the entire column first (vectorized), then updated specific rows with a mask (also vectorized).

9. When Vectorization Isn’t Enough

Sometimes even vectorized Pandas isn’t fast enough. Push past 10 million rows and you’ll likely hit the limits of RAM or Pandas’ single-threaded nature.

From here, three main options:

  1. Polars: A newer DataFrame library written in Rust. It runs faster than Pandas and handles multi-threading automatically.
  2. Numba: Write a standard Python loop, then compile it to machine code on the fly with @jit.
  3. DuckDB: With huge datasets, sometimes it’s faster to run a SQL query on the file directly rather than loading it into a DataFrame.

10. Recap and Next Steps

The takeaway is simple: reach for the fastest tool that expresses your logic.

  • Loops are slow — they run in the Python interpreter.
  • Vectorization is fast — it uses optimized C code and NumPy arrays.
  • Use .loc with boolean masks instead of if-else blocks.
  • Use groupby instead of manual grouping.
  • Resist apply(); look for a NumPy or Pandas built-in first.

Want a starting point? Pick one of your old scripts and refactor it today.

Check Your Understanding

Questions below move from simple recall up to open-ended design, roughly following Bloom’s Taxonomy.

Remember According to the article’s “Slowness Hierarchy,” where does .apply() rank compared to vectorized operations and groupby()?

Understand In your own words, explain why df.apply(lambda row: ..., axis=1) is called a “hidden for-loop” even though it doesn’t contain an explicit for keyword.

Apply Using the article’s “Fast Refactor” pattern (set a default column value, then override specific rows with a boolean mask), rewrite this loop-based logic without a loop: “for South-region rows, apply a 5% discount to the price column; leave all other rows unchanged.”

Analyze The article’s Section 6 explains that apply(axis=1) creates a new Series object for every row before passing it to your function. Walk through why this per-row object creation is expensive even before your function’s actual logic runs — what work is Pandas doing that a vectorized + operation on two columns skips entirely?

Evaluate The article’s Section 9 lists Polars, Numba, and DuckDB as options once vectorized Pandas isn’t fast enough. Critique jumping straight to one of these tools as step one for a slow pipeline, before checking the article’s Sections 1-8 techniques: what’s the risk of reaching for a new library before confirming the existing code isn’t just full of avoidable loops or apply() calls?

Create Design a refactor plan for a new slow script: it loops through a DataFrame of orders, and for each row, sets a shipping_tier column based on three tiers of order_total (under $50 = “standard”, $50-$200 = “priority”, over $200 = “express”). Using the article’s np.select pattern from Section 6, sketch the vectorized replacement.


References & Further reading

  • Pandas — Enhancing Performance — official guide to Cython, numba, and eval() optimizations for when built-in vectorization isn’t enough.
  • NumPy — Broadcasting — the underlying mechanism that lets vectorized Pandas/NumPy operations work on entire arrays without per-element Python overhead.

Apply What You Learned is for Supporter and Insider subscribers.

Subscribe to unlock the exercises on this post.

See plans

Looking for something else?

Search every article by title, summary or topic.