iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

apply / map

Pandas apply runs a function across rows, columns, or groups. It’s the catch-all for “I need custom logic per row” — but it’s also one of the slowest paths through Pandas, so know when to reach for vectorisation instead.

apply on Series, DataFrame, GroupBy

EXAMPLE
import pandas as pd
import numpy as np

# 1) Series.apply — element-wise transform
s = pd.Series([1, 2, 3, 4])
s.apply(lambda x: x ** 2)            # 1, 4, 9, 16
s.apply(np.sqrt)                      # 1.0, 1.414, 1.732, 2.0

# 2) Series.map — alias-ish; supports dict + Series mapping too
mapping = {'a': 1, 'b': 2}
pd.Series(['a', 'b', 'c']).map(mapping)    # 1, 2, NaN
pd.Series(['a', 'b']).map(lambda x: x.upper())   # A, B

# 3) DataFrame.apply along axis
df = pd.DataFrame({'a': [1, 2, 3], 'b': [10, 20, 30]})
df.apply(np.sum)                              # column sums  -> Series (a=6, b=60)
df.apply(np.sum, axis=1)                       # row sums      -> Series (11, 22, 33)

df.apply(lambda col: col.max() - col.min())  # range per column
df.apply(lambda row: row['a'] / row['b'], axis=1)

# 4) Returning multiple columns from apply
df.apply(lambda r: pd.Series({'sum': r['a'] + r['b'], 'prod': r['a'] * r['b']}), axis=1)
#    sum  prod
# 0  11    10
# 1  22    40
# 2  33    90

# 5) Result type — Series vs DataFrame vs scalar
# If the function returns a Series, the whole apply returns a DataFrame.
# If it returns a scalar, the whole apply returns a Series.
# If it returns a list/tuple, Pandas may pick column-wise — easier to explicitly return pd.Series.

# 6) Working with strings — .str accessor is faster than apply
s = pd.Series(['mara', 'sam', 'alex'])
s.str.upper()                                  # faster than s.apply(str.upper)
s.str.startswith('m')
s.str.contains(r'^[aeiou]', regex=True)
s.str.split('-', expand=True)                  # creates a DataFrame

# 7) Numeric vectorisation — beat apply on speed
N = 10_000_000
x = pd.Series(np.random.randn(N))

# Slow
# x.apply(lambda v: v * 2 + 1)

# Fast — vectorised
x * 2 + 1

# Conditional vectorisation
np.where(x > 0, x * 2, x * -1)
x.where(x > 0, -x)                              # keep x where condition is True, else -x

# 8) GroupBy.apply — the workhorse for per-group transforms
df = pd.DataFrame({
    'team':  ['A', 'A', 'B', 'B', 'B'],
    'score': [10, 20, 5, 15, 25],
})
df.groupby('team')['score'].apply(lambda s: (s - s.min()) / (s.max() - s.min()))
# Normalise scores per team.

# Pull only the top-K per group
df.groupby('team').apply(lambda g: g.nlargest(2, 'score'))

# 9) GroupBy.transform — like apply but returns the same shape
df['z'] = df.groupby('team')['score'].transform(lambda s: (s - s.mean()) / s.std())
# z-score within each team; result is aligned to original rows automatically.

# 10) GroupBy.agg — when you want a SUMMARY per group
df.groupby('team')['score'].agg(['mean', 'max', 'min'])
df.groupby('team').agg(
    mean_score=('score', 'mean'),
    max_score=('score', 'max'),
    range_score=('score', lambda s: s.max() - s.min()),
)

# 11) When apply is the right tool
# • Custom per-row logic that can't be vectorised easily
# • Per-group transforms requiring inter-row context (regression, sort)
# • One-off scripts where readability beats speed
# • Small frames (< 100k rows) where speed isn't critical

# 12) When apply is the WRONG tool
# • Numeric arithmetic — use vectorised ops
# • Conditional logic — use np.where, np.select, .mask
# • String operations — use .str accessor
# • Date operations — use .dt accessor
# • Multiple aggregations — use .agg

# 13) Performance progression — fastest to slowest
# 1. Vectorised NumPy/Pandas (df.a + df.b)              ~1x
# 2. .str / .dt accessors                                ~3-5x slower than 1
# 3. .map with dict                                       ~5x
# 4. .apply with lambda on Series                          ~10-20x
# 5. .apply on DataFrame (axis=1)                          ~50-200x slower
# 6. for row in df.itertuples()                            ~100-500x slower
# 7. df.iterrows()                                          ~1000x — almost never use
#
# When in doubt, profile with %timeit before optimising.

# 14) Speed tricks for apply on rows
# Use raw=True with numpy ufuncs:
df.apply(lambda r: r.sum(), axis=1, raw=True)   # 2-3x faster (no Series wrapping)

# Convert to numpy once + vectorise:
arr = df[['a', 'b']].to_numpy()
result = arr[:, 0] / arr[:, 1]

# 15) Returning structured data
def parse(row):
    domain = row['email'].split('@')[1]
    return pd.Series({'domain': domain, 'is_corporate': domain in {'example.com'}})

df = pd.DataFrame({'email': ['mara@example.com', 'sam@gmail.com']})
result = df.apply(parse, axis=1)
#                   domain  is_corporate
# 0       example.com          True
# 1         gmail.com         False

# 16) apply with non-DataFrame return + result_type
result_type = 'expand'           # each list element -> column
result_type = 'reduce'            # wrap a non-list-like result
result_type = 'broadcast'         # broadcast across the axis

# 17) Progress bars for slow applies — tqdm
from tqdm import tqdm
tqdm.pandas()
df.progress_apply(lambda r: heavy(r), axis=1)

# 18) Parallel apply — pandarallel / swifter
# Last resort for genuinely CPU-bound applies; not magic.
# from pandarallel import pandarallel
# pandarallel.initialize(progress_bar=True)
# df.parallel_apply(fn, axis=1)

# 19) Common bugs
# • Calling apply with a function that mutates external state — race conditions if parallel
# • Forgetting axis=1 when working per-row — operates per-column instead
# • Returning lists/tuples expecting columns — use pd.Series for predictable expansion
# • Slow row-wise apply on millions of rows — refactor to vectorised ops
# • map vs apply on Series — map is slightly faster but doesn't accept positional kwargs
# • apply on GroupBy with side effects on g — produces unexpected output as Pandas may run on the whole group plus probe
# • Modifying df inside apply — undefined behaviour; return values instead

Why it matters

apply is the catch-all for custom row/column logic, but it’s 50–200× slower than vectorised NumPy. Try vectorised ops, np.where, the .str and .dt accessors, and GroupBy transform/agg first; reach for apply when the logic genuinely needs per-row context and the frame isn’t huge.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
import pandas as pd
df['initials'] = df['name'].apply(lambda n: ''.join(p[0] for p in n.split()))
df['age_bucket'] = df['age'].map(lambda a: 'adult' if a >= 18 else 'minor')
Try it Yourself »

Discussion

Loading…