Pivot tables and crosstab
pivot_table with aggregators, crosstab with normalisation, margins, multi-level columns and flattening the result into something tableable.
pivot_table
import pandas as pd
pivot = df.pivot_table(
index="region",
columns="product",
values="revenue",
aggfunc="sum",
fill_value=0,
margins=True,
margins_name="Total",
)
pivot.loc["north", "widget"] # one cell by label
pivot / pivot.loc["Total"] # share of total, per cell, in one line
multi = df.pivot_table(
index="region",
columns="product",
values="revenue",
aggfunc=["sum", "mean"], # one column block per aggregator
)
multi.columns.names # FrozenList([None, 'product'])pivot_tableaggregates: repeated index and column combinations are combined byaggfunc, so duplicates are harmless.- Plain
pivotdoes not aggregate and raises when a combination repeats. - Missing combinations become
NaNby default;fill_value=0is right for counts and revenue, wrong for averages. margins=Trueadds a Total row and column computed by the sameaggfuncover the whole frame.
crosstab and normalisation
counts = pd.crosstab(df["region"], df["product"])
shares = pd.crosstab(df["region"], df["product"], normalize="index") # row percentages
totals = pd.crosstab(
df["region"], df["product"],
values=df["revenue"], aggfunc="sum", margins=True,
)
pd.crosstab(df["region"], [df["product"], df["channel"]]) # cross two columns
pd.crosstab(df["region"], df["product"]).stack().to_frame("n") # back to long| Aggregator | Use it for |
|---|---|
"sum" | Revenue, quantity, anything additive |
"count" / "size" | Number of rows per cell |
"mean" | Average order value per cell |
"nunique" | How many distinct customers appear |
lambda s: s.value_counts().index[0] | The most common value in the cell |
⚠️
Margins are honest only for additive aggregators. With
aggfunc="mean" the Total row is the mean of the cell means, not the mean of the underlying rows - a different number whenever the cell sizes differ. Compute the overall figure separately.Flattening for the next reader
flat = pivot.copy()
flat.columns = ["_".join(str(p) for p in c).strip("_") for c in flat.columns]
flat.columns.name = None
flat.index.name = "region"
flat = flat.reset_index()
# or, keeping the hierarchy but renaming levels
long = multi.stack(level="product", future_stack=True).rename_axis(
columns=["stat", "product"]
).reset_index()A MultiIndex column set is fine inside a notebook and awkward everywhere else: CSV writers flatten it into odd header lines, plotting libraries guess, and dashboards impose their own convention. Decide the flat column names explicitly before the data leaves your process.
FAQ
pivot or pivot_table?
Use
pivot_table unless you have proved the key pairs are unique. It aggregates instead of failing, and it fills gaps on request, which is what almost every report actually needs.How do I get share-of-total percentages?
normalize="index" in crosstab divides each row by its total, normalize="columns" by each column, and normalize=True by the grand total.Related
The index in depth: MultiIndex, set_index and reindex apply, map and writing fast pandas code
Last refreshed 2026-09-18.