apply vs vectorized operations

pandas · Practical · 12 min

apply is the most common cause of slow pandas code; knowing the alternatives often gives 100x speedups.

apply calls a Python function repeatedly: s.apply(f) once per element, df.apply(f) once per column, and df.apply(f, axis=1) once per row, building a Series for each row. That's flexible and easy to write, but it's a Python-level loop. On a million rows, axis=1 apply can take tens of seconds where a vectorized expression takes milliseconds.

Prefer, in this order:

  • Column arithmetic and ufuncs: df.a * df.b, np.log(df.x), df.x.clip(0, 100).
  • Built-in methods: .str, .dt, round, abs, cumsum, diff, shift, between, isin.
  • Conditional logic: np.where, np.select, Series.where/mask, pd.cut.
  • Lookups: s.map(dict_or_series); merge for multi-column lookups.
  • Group-wise work: groupby with built-in aggregations or transform.

s.map(func) and s.apply(func) on a Series are equivalent loops; map with a dict is a fast hash lookup. DataFrame.map (formerly applymap, renamed in 2.1) applies element-wise to every cell, also a loop.

Legitimate uses of apply remain: calling a library function that only accepts scalars (a geocoder, a parser), complex per-group logic in groupby(...).apply, or small data where clarity matters more than speed. When you do need a loop over rows, itertuples() is much faster than iterrows(), which builds a Series per row and loses dtypes.

If a computation truly can't be vectorized and the data is large, push the loop into compiled code with Numba, or rethink the algorithm. Measure first: %timeit on a realistic sample tells you whether it matters.

Example

import numpy as np
import pandas as pd
import time

rng = np.random.default_rng(0)
df = pd.DataFrame({"price": rng.uniform(1, 100, 200_000),
                   "qty": rng.integers(0, 10, 200_000)})

def tier_row(row):                       # row-wise Python logic
    total = row["price"] * row["qty"]
    return "big" if total > 300 else "small" if total > 0 else "none"

t = time.perf_counter()
slow = df.apply(tier_row, axis=1)
t_apply = time.perf_counter() - t

t = time.perf_counter()
total = df["price"] * df["qty"]
fast = pd.Series(np.select([total > 300, total > 0], ["big", "small"], "none"), index=df.index)
t_vec = time.perf_counter() - t

print(slow.equals(fast))                 # True
print(f"apply {t_apply:.2f}s, vectorized {t_vec:.3f}s")   # typically 30-100x apart

codes = pd.Series(["N", "S", "N"])
print(codes.map({"N": "North", "S": "South"}).tolist())   # fast dict lookup

Remember

  • apply(axis=1) is a Python loop over rows and is slow on large data
  • Prefer column arithmetic, built-in methods, np.where/np.select and map with dicts
  • Use itertuples rather than iterrows if you must loop
  • apply is fine for small data or scalar-only library calls

Watch out for

  • Reaching for apply(lambda row: ..., axis=1) for simple arithmetic between columns.

Check yourself

  1. Which is the fastest way to compute `a * b` for two numeric columns?
  2. Why is `iterrows()` especially slow and lossy?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: pandas.pydata.org/docs/user_guide/enhancingperf.html