Cross-Tabulation and Pivot Table Depth
One question, three tools
“How many rows fall into each combination of category A and category B?” is one of
the most common analytics questions — and pandas has three ways to answer it:
groupbygroupby, pivot_tablepivot_table, and crosstabcrosstab. They overlap on purpose; picking the right
one is mostly about which shape of answer you want.
Example dataset
import pandas as pd
survey = pd.DataFrame({
"nationality": ["USA", "Japan", "USA", "Japan", "Japan", "USA"],
"handedness": ["Right", "Left", "Right", "Right", "Left", "Right"],
})
print(survey)import pandas as pd
survey = pd.DataFrame({
"nationality": ["USA", "Japan", "USA", "Japan", "Japan", "USA"],
"handedness": ["Right", "Left", "Right", "Right", "Left", "Right"],
})
print(survey)crosstab: counting combinations
pd.crosstab(rows, columns)pd.crosstab(rows, columns) counts how many times each combination of two (or more)
categorical values occurs — no values=values= or aggfunc=aggfunc= needed, it’s frequency by
default:
print(pd.crosstab(survey["nationality"], survey["handedness"]))print(pd.crosstab(survey["nationality"], survey["handedness"]))Adding row/column totals with margins
print(pd.crosstab(survey["nationality"], survey["handedness"], margins=True))print(pd.crosstab(survey["nationality"], survey["handedness"], margins=True))The "All""All" row and column are the totals across that dimension — 3 Japanese
respondents, 3 American respondents, 6 total.
Normalizing into proportions
Raw counts don’t tell you whether a pattern is proportionally strong. Pass
normalize="index"normalize="index" to turn each row into percentages that sum to 1:
print(pd.crosstab(survey["nationality"], survey["handedness"], normalize="index"))print(pd.crosstab(survey["nationality"], survey["handedness"], normalize="index"))Now you can see that 100% of the American respondents in this sample are right-handed, versus about 33% of the Japanese respondents.
pivot_table: aggregating a numeric column by category
pivot_tablepivot_table answers a related but different question: not “how many rows,”
but “what’s the sum (or mean, or any aggregate) of some numeric column,”
broken down by category:
sales = pd.DataFrame({
"city": ["pune", "pune", "delhi", "delhi", "delhi", "pune"],
"category": ["A", "B", "A", "A", "B", "A"],
"amount": [100, 200, 150, 120, 300, 80],
})
summary = sales.pivot_table(index="city", columns="category", values="amount", aggfunc="sum")
print(summary)sales = pd.DataFrame({
"city": ["pune", "pune", "delhi", "delhi", "delhi", "pune"],
"category": ["A", "B", "A", "A", "B", "A"],
"amount": [100, 200, 150, 120, 300, 80],
})
summary = sales.pivot_table(index="city", columns="category", values="amount", aggfunc="sum")
print(summary)margins and multiple aggregations
Just like crosstabcrosstab, pivot_tablepivot_table accepts margins=Truemargins=True for row/column totals. It
also accepts a list of aggregation functions to compute several statistics at once:
print(sales.pivot_table(index="city", columns="category", values="amount", aggfunc="sum", margins=True))print(sales.pivot_table(index="city", columns="category", values="amount", aggfunc="sum", margins=True))print(sales.pivot_table(index="city", values="amount", aggfunc=["sum", "mean"]))print(sales.pivot_table(index="city", values="amount", aggfunc=["sum", "mean"]))crosstab vs pivot_table vs groupby
| Tool | Best for | Default aggregation |
|---|---|---|
groupby(...).agg(...)groupby(...).agg(...) | Full control, any number of metrics/columns | You choose |
pivot_tablepivot_table | A numeric column, aggregated by 1-2 categorical keys, arranged as a grid | "mean""mean" |
crosstabcrosstab | Just counting how often category combinations occur | "count""count" |
Anything crosstabcrosstab can do, pivot_table(..., aggfunc="count")pivot_table(..., aggfunc="count") can also do — reach
for crosstabcrosstab when you specifically want frequencies, since it reads more clearly.
Common pitfalls
pivot_tablepivot_table’s defaultaggfuncaggfuncis"mean""mean"— if you actually want totals, you must passaggfunc="sum"aggfunc="sum"explicitly, or you’ll get a quietly wrong answer.- Empty (index, column) combinations become
NaNNaNunless you passfill_value=0fill_value=0. margins=Truemargins=True’s"All""All"row/column is computed over the entire dataset, not just an average of the other rows/columns you can see — don’t average it in by hand.
Visualize it
flowchart LR D["Rows split by category"] --> G["groupby().agg()
your choice of metrics"] D --> P["pivot_table()
numeric column, grid of categories"] D --> C["crosstab()
frequency grid of categories"]
🧪 Try It Yourself
Exercise 1 – Count Combinations With crosstab
Exercise 2 – Sum a Numeric Column With pivot_table
Exercise 3 – Add Grand Totals With margins
Next
That wraps up grouping and summarizing. Continue to Hierarchical Indexing (MultiIndex) to see how a two-level row index gives you similar structure without reshaping into a grid at all.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
