How do I flatten the column names after a groupby with multiple aggregations in pandas?
Short answer
A groupby with several aggregations produces two-level column names. Flatten them by joining the levels into single strings, or avoid them entirely with named aggregation.
The short version
When you aggregate a column with several functions, pandas gives the result a two-level (MultiIndex) column header, which is awkward to work with. Flatten it by joining the two levels into one string, or sidestep it altogether by using named aggregation, which produces flat names from the start.
Where the two-level header comes from
import pandas as pd
df = pd.DataFrame({"city": ["Leeds", "Leeds", "Bath"], "amount": [10, 20, 5]})
agg = df.groupby("city").agg({"amount": ["sum", "mean"]})
print(agg.columns.tolist())
# [('amount', 'sum'), ('amount', 'mean')]
Each column is now a tuple of (column, function), so agg["amount"]["sum"] works but plain agg["amount_sum"] does not. That two-level header is the thing people want to get rid of.
Flattening by joining the levels
Rebuild the columns by joining each tuple into a single underscore-separated name.
agg.columns = ["_".join(col) for col in agg.columns]
agg = agg.reset_index()
print(agg.columns.tolist())
# ['city', 'amount_sum', 'amount_mean']
Now the columns are ordinary strings, and the grouping key is back as a column thanks to reset_index.
The cleaner way: named aggregation
Better still, avoid the MultiIndex in the first place. Named aggregation lets you state each output column's name directly.
df.groupby("city").agg(
total=("amount", "sum"),
average=("amount", "mean"),
).reset_index()
This produces flat total and average columns with no flattening step needed, and it reads clearly because each output name sits next to the calculation that fills it. Prefer this for new code.
Why the MultiIndex appears at all
The two-level header is not a bug; it is how pandas represents "several statistics of several columns" unambiguously. If you aggregate three columns with two functions each, the tuple form keeps every combination distinct. The header only feels like a nuisance when you have one column or want simple names, which is exactly when named aggregation or flattening helps.
A reusable flatten helper
If you often receive already-aggregated frames with MultiIndex columns, a small helper keeps your code tidy.
def flatten(cols):
return ["_".join(str(c) for c in col).strip("_") for col in cols]
agg.columns = flatten(agg.columns)
The strip("_") tidies cases where one level is empty, which happens when only some columns were aggregated.