The natural way to write a row-by-row calculation

Computing a total from a price and a quantity, with tax, reads naturally as a function applied to each row:

def compute_total(row):
    return row["price"] * row["quantity"] * 1.08

df["total"] = df.apply(compute_total, axis=1)

.apply(..., axis=1) calls compute_total once per row, which is exactly what the code seems to ask for, and it produces the correct answer.

What it costs to get there

import time

start = time.perf_counter()
df.apply(compute_total, axis=1)
print(f"{time.perf_counter() - start:.3f} s")
2.320 s

On 500,000 rows, a little over two seconds. Now the same calculation, written without a function at all:

start = time.perf_counter()
df["price"] * df["quantity"] * 1.08
print(f"{time.perf_counter() - start:.4f} s")
0.0021 s

Both produce an identical result, confirmed directly, np.allclose() on the two output columns returns True. One takes two seconds. The other takes two milliseconds. Over a thousand times faster, for the exact same arithmetic on the exact same data.

Why the difference is this large

df.apply(fn, axis=1) genuinely runs a Python function call, in the regular Python interpreter, once for every single row, half a million separate calls for this example, each one carrying real Python function-call overhead on top of the actual multiplication. df["price"] * df["quantity"] never loops in Python at all. It hands the whole column to NumPy, which performs the multiplication as one operation over a contiguous block of memory, in compiled C, with none of the per-element interpreter overhead a Python-level loop carries. This is what "vectorization" means concretely: replacing many small Python-level operations with one large operation handed off to code that isn't paying Python's per-step cost.

.apply(axis=1) looking like the natural, row-oriented way to express the calculation is exactly what makes this easy to write without noticing, it reads as "do this to each row," and pandas happily does precisely that, at a real, measurable cost that only shows up once the data is large enough to make two seconds versus two milliseconds a difference anyone would notice.

When .apply() is still the right tool

Not every row-wise operation has a vectorized equivalent. Calling an external API per row, applying genuinely row-dependent branching logic that doesn't reduce to arithmetic, or running a function from a library that only accepts one value at a time, none of those have a clean vectorized form, and .apply() is a real, reasonable choice for them. The performance cliff specifically applies to calculations that could be expressed as direct operations on whole columns, price times quantity, a comparison, a string method, and were written as a per-row function instead, out of habit or because the row-wise phrasing came to mind first.

The takeaway

Before reaching for .apply(axis=1), it's worth asking whether the calculation could be written as a direct operation on the columns themselves, arithmetic, comparisons, and most of pandas' and NumPy's own built-in methods are already vectorized. When it can, the rewrite is often smaller than the original function, not larger, and the performance difference at real data scale isn't a marginal optimization, it's the difference between a report that returns instantly and one that visibly makes someone wait.