Skip to content
datarekha
Python August 15, 2026

apply() is a for loop in disguise

Rewriting a Python loop as .apply() makes the code shorter and almost never makes it faster, because .apply() is the same loop with a nicer costume. Here is the ladder that does make it faster.

11 min read · by datarekha · pandasnumpyperformancevectorizationpython

Somebody notices a slow loop in a notebook. They rewrite it as .apply() because that is what idiomatic pandas looks like. The code drops from six lines to one, the diff looks like an optimisation, and the runtime is unchanged.

That is not bad luck. .apply() is the loop. It iterates over your rows, calls your Python function once per row, and collects the results. The for keyword is gone; the interpreter dispatch, the object boxing, the function-call overhead, and the reference counting are all still there, happening once per row, just as before.

Understanding this is the difference between guessing at pandas performance and predicting it. And the prediction rule is short: count how many times your Python function gets called.

What actually happens on each call

When you write df["score"].apply(f), pandas walks the underlying array and, for every element, does roughly the following: take the raw value out of a typed C buffer, wrap it in a full Python object, push a frame for f, execute f’s bytecode in the interpreter, take the returned Python object, and store it. Then it does that again. Five million times.

df.apply(f, axis=1) is worse, and this is the version people reach for most. For every row, pandas must construct a Series — an actual pandas object, with an index built from the column names — hand it to your function, and throw it away. You are allocating one indexed container per row of your DataFrame.

Compare that with df["a"] + df["b"]. NumPy receives two contiguous typed buffers and runs one compiled loop over them, with no Python objects created per element, no interpreter dispatch, and memory access patterns the CPU’s prefetcher can predict. There is exactly one dispatch for the whole column.

The asymptotic complexity is identical — both are O(n). The constant factor is not, and on the scale of a real dataset the constant factor is the entire story. Expect differences of one to two orders of magnitude for arithmetic, and describe them to yourself that way: not “apply is slow” but “apply costs one interpreter round trip per element, and the vectorised form costs one per column.”

.apply(f) — n dispatchestyped buffer: 5 000 000 valuesbox each valuePython interpreterframe push, bytecode, refcount— once per element —object array of results5 000 000 round tripsdf.a + df.b — one dispatchtyped buffer: 5 000 000 valuesone call, whole arraycompiled C loopno boxing, predictable strides,CPU can prefetch and vectorisetyped result buffer1 round trip
Both are O(n) passes over the data. The difference is how many times control crosses the boundary between compiled code and the interpreter.

The ladder

Rungs, from most expensive to least. Climb until the operation is fast enough, not until you reach the top.

Rung 0 — iterrows(). The worst option available, and still common in tutorials. It constructs a Series per row and it flattens your row to a single dtype, so an integer column read through iterrows may come back as a float or an object. If you must iterate, itertuples() is dramatically cheaper and preserves types.

Rung 1 — .apply(). Same call count as an explicit loop, better ergonomics, and slightly better internals for the Series case. With axis=1, passing raw=True skips the per-row Series construction and hands your function a raw NumPy array instead, which is a real and free improvement when your function only needs positional access.

Rung 2 — .map() with a dictionary or Series. For the “translate this value into that value” pattern, .map() against a dict avoids invoking a Python callable per element and is consistently faster than .apply(lambda x: d[x]). Any time your lambda body is a lookup, this is the rung you want.

Rung 3 — column arithmetic. df["total"] = df["qty"] * df["price"]. One dispatch for the entire column. Everything expressible as arithmetic, comparison, or boolean logic on whole columns belongs here, and this is where most .apply() calls in real code should have been all along.

Rung 4 — np.where and np.select for conditionals. The single most common .apply() in the wild is a branch:

df["tier"] = df.apply(lambda r: "high" if r.score > 80 else "low", axis=1)

which is exactly

df["tier"] = np.where(df["score"] > 80, "high", "low")

For more than two branches, np.select takes a list of condition arrays and a matching list of results, evaluated in order with a default. For numeric banding, pd.cut does the same job with interval semantics.

Rung 5 — the .str and .dt accessors. df["email"].str.lower(), df["ts"].dt.dayofweek. These are the vectorised forms of the string and datetime operations people reach for .apply() to do. Be honest about what they cost: with the classic object dtype, pandas is still looping over Python string objects internally, so the win over .apply() is real but modest. With Arrow-backed string dtypes, the same operations run in compiled Arrow kernels and the win becomes large. If your workload is string-heavy, the dtype choice matters more than the syntax.

Rung 6 — a different engine. When the logic is genuinely per-row and genuinely irreducible, the answer is to compile it or to change tools: numba to JIT the loop over raw arrays, or Polars and DuckDB, which execute expression graphs in compiled code and parallelise across cores by default. This rung is a real engineering decision, not a one-line rewrite.

When apply is genuinely fine

The rule is the call count, not the method name. .apply() costs one interpreter round trip per invocation, so the question is always: how many invocations?

groupby().apply() over a modest number of groups is fine. If you have five million rows in fifty groups, your function is called fifty times. Fifty Python calls is nothing. The expensive work — the grouping itself — happens in compiled code. This is why the same method name can be a disaster in one place and completely appropriate in another.

Small data is fine. On ten thousand rows, the entire .apply() finishes in the time it takes you to read the alternative. Optimising it is a worse use of your afternoon than the runtime it saves over the object’s whole life.

Irreducibly irregular logic is fine. A function that calls an external API, walks a nested JSON structure with variable depth, runs a stateful parser, or dispatches on a type that varies per row is not going to vectorise. Write the loop, be explicit about it, and if the volume is large, parallelise it or move it out of pandas entirely.

Prototyping is fine. Write the .apply(), confirm the logic is right, and then vectorise it with the .apply() result as your test oracle. That is a good workflow, not a compromise.

Measure it, do not assume it

Two habits keep this honest.

First, time the real thing on the real volume. %timeit in a notebook on the actual column, not a thousand-row sample — the whole point is that the constant factor only becomes visible at scale, and a sample is precisely where it hides.

Second, profile before rewriting. cProfile and line_profiler will show you the hit count of the line inside your callback, and a hit count equal to your row count is the signature of a per-row dispatch. If the profiler says your job spends eighty percent of its time reading Parquet, vectorising the .apply() will buy you nothing at all.

The failure mode here is real: people rewrite a readable .apply() into a dense stack of np.select calls, lose an afternoon, add a bug, and save two seconds on a job that runs weekly. Speed is only worth what it saves you.

The intuition to carry forward

pandas and NumPy are fast because they hand whole arrays to compiled code. Every time you insert a Python callable into the middle of that, you convert one compiled loop into n interpreter round trips, and you pay for it in proportion to your row count.

So the question to ask of any pandas line is not “is this vectorised?” — that word gets used loosely enough to mean nothing. It is: how many times does Python get called? Once, and you are fine. Once per group over fifty groups, and you are fine. Once per row over five million rows, and you have written a loop, whatever the syntax looks like.

Learn it as a system

Start with Why NumPy for the memory model that makes any of this possible — contiguous typed buffers versus Python’s boxed objects — then read Universal functions (ufuncs) for how a single dispatch runs a compiled loop across an entire array, including the conditional and reduction forms. Finish with GroupBy — split, apply, combine to see why the same apply costs almost nothing once the callback runs per group instead of per row.