Stop Chaining apply(): pandas Named Aggregation Does It in One Pass
pandas named aggregation replaces chained apply() calls and MultiIndex flattening with one declarative groupby-agg call. A worked example, the performance trade-offs, and where it can't help.
14 Aug 2025, 20:09 UTC

If you've ever written a groupby that ends with a lambda, a rename, and a reset_index just to get readable column names, there's a cleaner path. Since pandas 0.25, named aggregation lets you declare your output columns directly: df.groupby('customer').agg(orders=('order_id', 'size'), total_spent=('amount', 'sum')). One call, flat columns, no MultiIndex surgery.
This post shows what that looks like on a realistic orders table, why it's usually faster than the equivalent apply, and where it genuinely can't help you.
The problem with the old patterns
Before named aggregation, per-group summaries typically went one of two ways:
- Dict-of-dicts aggregation:
df.groupby('customer').agg({'amount': ['sum', 'mean'], 'order_id': 'size'}). This works, but the result has a MultiIndex on the columns (('amount', 'sum')), so you end up flattening names by hand or writing a join-and-rename step. - Custom
apply:df.groupby('customer').apply(lambda g: pd.Series({...})). Flexible, but each group is materialized as a DataFrame and passed through Python, which is slow at scale and obscures intent.
Both make the reader reverse-engineer what the output columns are supposed to be. Named aggregation puts the output schema in the code itself.
A worked example: per-customer order summary
Assume an orders DataFrame with columns customer, order_id, amount, and items. The summary we want: order count, total spend, average basket value, and distinct item categories touched — one row per customer.
import pandas as pd
summary = (
df.groupby('customer')
.agg(
orders=('order_id', 'size'),
total_spent=('amount', 'sum'),
avg_basket=('amount', 'mean'),
first_order=('order_id', 'first'),
)
.reset_index()
)
The keyword name becomes the output column; the tuple is (source_column, function). The function can be a string alias ('sum', 'mean', 'size', 'nunique', 'first', 'last'), a NumPy reduction like np.median, or a callable. Because the whole thing is one expression, it chains naturally — you can tack on .sort_values('total_spent', ascending=False) without intermediate variables.
Run this in any environment with pandas installed (pip install "pandas>=2.0") and inspect summary.columns: you should see exactly customer, orders, total_spent, avg_basket, first_order — no MultiIndex, no flattening step.
Why it's usually faster, not just prettier
When you pass built-in aliases or NumPy reductions, pandas dispatches to optimized internal (Cython) aggregation kernels that operate on whole columns per group. With apply, pandas must build a sub-DataFrame per group and hand it to your Python function — real interpreter overhead per group, which grows with the number of groups.
How much faster depends on your data: group count, dtypes, and which functions you use. Don't take anyone's benchmark numbers (including mine) as a promise — time it yourself:
%timeit df.groupby('customer').agg(total=('amount', 'sum'))
%timeit df.groupby('customer').apply(lambda g: g['amount'].sum(), include_groups=False)
Run both in a notebook against a representative slice of your own data. Note the include_groups=False argument: in recent pandas versions, operating on the grouping columns inside apply is deprecated, and this keyword opts into the future behavior. Check the docs for your installed version, since this changed across the 2.x line.
Where named aggregation falls short
The tuple form maps one source column to one function. Anything whose output depends on multiple columns at once doesn't fit. A weighted average is the classic case:
# Cannot be expressed as ('price', some_func) — needs price AND quantity
wavg = (
df.groupby('customer')
.apply(lambda g: (g['price'] * g['quantity']).sum() / g['quantity'].sum(),
include_groups=False)
)
You have three honest options here:
- Keep the
apply— it's the right tool for genuinely multi-column logic. - Precompute a helper column (
df['revenue'] = df['price'] * df['quantity']) and aggregate that with named aggregation, which often recovers the fast path. - Pass a custom callable as the function in a tuple, e.g.
('amount', lambda s: s.quantile(0.9)). This works, but custom callables skip the optimized path — you keep the readability, lose some of the speed.
One more caveat: defaults around dropna in groupby and observed= for categorical groupers have shifted across pandas versions. If your grouping column is categorical or contains NaNs, pin your pandas version and verify the row count of the result matches your expectation.
The takeaway
Named aggregation turns groupby summaries into declarative, self-documenting code: output names up front, flat columns out, optimized kernels underneath. Reach for it whenever each metric comes from a single column. When the logic genuinely spans columns, use apply without guilt — or restructure with a precomputed column so you don't have to. Either way, verify on your own data: check the output schema with .columns, sanity-check a couple of groups by hand, and time both versions before claiming a win.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.