crosstab(): Frequency Tables
Count how often each combination of two categories occurs with pd.crosstab(), add totals with margins, and turn counts into row/column percentages with normalize.
"After winning the toss, how often does each captain choose to bat or field?" "Do teams that field first win more?" Both are about two categorical columns and how often each combination happens. A table of those counts is called a cross-tabulation (or contingency table), and pandas builds it with pd.crosstab().
python
m = pd.DataFrame({
"toss_winner": ["MI", "CSK", "MI", "RCB", "CSK", "MI", "RCB", "CSK", "MI", "RCB"],
"toss_decision": ["field", "bat", "field", "field", "field", "bat", "bat", "field", "field", "field"],
"result": ["won", "won", "lost", "won", "won", "lost", "lost", "won", "won", "lost"],
"margin": [6, 32, 4, 18, 7, 15, 9, 5, 8, 21],
})Definition
pd.crosstab()computes a frequency table of two (or more) categorical variables: the unique values of one become the rows, the unique values of the other become the columns, and each cell holds how many rows have that combination (or, optionally, an aggregate of a third column).
- Frequency — a count of occurrences. By default every cell is a count.
- Contingency table — the statistics name for this table; it shows how one category's distribution depends on another.
- Specialised pivot table — crosstab is a pivot table whose default aggregation is counting, and it takes arrays/Series rather than column names.
Syntax
python
pd.crosstab(index, columns, values=None, aggfunc=None, margins=False,
margins_name="All", normalize=False, rownames=None, colnames=None, dropna=True)| Parameter | Meaning | Default |
|---|---|---|
index | Series/array (or list of them) for the rows | required |
columns | Series/array (or list of them) for the columns | required |
values | optional Series to aggregate instead of counting (needs aggfunc) | None |
aggfunc | how to aggregate values ("mean", "sum"…) | None (count) |
margins | add row/column totals | False |
margins_name | label for the totals | "All" |
normalize | True/"all": divide by grand total; "index": each row sums to 1; "columns": each column sums to 1 | False |
rownames / colnames | names to show for the row/column axes | taken from the Series names |
Example — toss winner vs decision
python
print(pd.crosstab(m["toss_winner"], m["toss_decision"], margins=True))text
toss_decision bat field All
toss_winner
CSK 1 2 3
MI 1 3 4
RCB 1 2 3
All 3 7 10How it works: pandas counts every (toss_winner, toss_decision) pair: MI won the toss 4 times and fielded 3 of them. margins=True adds totals: 7 of 10 captains chose to field.
Example — normalize: shares instead of counts
python
print(pd.crosstab(m["toss_decision"], m["result"], normalize="index").round(2))text
result lost won
toss_decision
bat 0.67 0.33
field 0.29 0.71How it works: normalize="index" divides each cell by its row total, so each row sums to 1. Read it as: "Of the teams that chose to field, 71% won." Compare the options:
normalize= | Each cell divided by | Sums to 1 across | Answers |
|---|---|---|---|
"index" | its row total | each row | "Given the decision, how often did they win?" |
"columns" | its column total | each column | "Of all wins, what share came after fielding?" |
True / "all" | grand total | whole table | "What share of all matches is field + won?" |
python
print(pd.crosstab(m["toss_decision"], m["result"], normalize="columns").round(2))
print(pd.crosstab(m["toss_decision"], m["result"], normalize="all"))text
result lost won
toss_decision
bat 0.5 0.17
field 0.5 0.83
result lost won
toss_decision
bat 0.2 0.1
field 0.2 0.5Example — aggregate a value instead of counting
python
print(pd.crosstab(m["toss_winner"], m["result"], values=m["margin"], aggfunc="mean"))text
result lost won
toss_winner
CSK NaN 14.666667
MI 9.5 7.000000
RCB 15.0 18.000000With values + aggfunc, each cell is the average winning/losing margin. CSK never lost, so that cell is NaN.
Example — more than two variables, custom labels
python
print(pd.crosstab(m["toss_winner"], [m["toss_decision"], m["result"]]))
print(pd.crosstab(m["toss_winner"], m["result"], rownames=["Team"], colnames=["Outcome"],
margins=True, margins_name="Total"))text
toss_decision bat field
result lost won lost won
toss_winner
CSK 0 1 0 2
MI 1 0 1 2
RCB 1 0 1 1
Outcome lost won Total
Team
CSK 0 3 3
MI 2 2 4
RCB 2 1 3
Total 4 6 10A list of Series gives MultiIndex columns. Note that crosstab fills impossible combinations with 0 counts (not NaN) when counting.
crosstab vs pivot_table vs groupby
All three can build the same count table:
python
pd.crosstab(m["toss_winner"], m["toss_decision"])
m.pivot_table(index="toss_winner", columns="toss_decision", aggfunc="size", fill_value=0)
m.groupby(["toss_winner", "toss_decision"]).size().unstack(fill_value=0)text
toss_decision bat field
toss_winner
CSK 1 2
MI 1 3
RCB 1 2pd.crosstab | pivot_table | groupby + unstack | |
|---|---|---|---|
| Inputs | Series / arrays | DataFrame + column names | DataFrame + column names |
| Default cell | count | mean of values | whatever you aggregate |
| Percentages | built in (normalize) | manual | manual |
| Best for | frequency of category combinations | numeric summaries | maximum flexibility |
Common mistakes
- ❌
pd.crosstab("toss_winner", "result")with column names. ✅ Pass Series:pd.crosstab(m["toss_winner"], m["result"]). - ❌ Passing
valueswithoutaggfunc(or vice versa) →ValueError. ✅ Always use them together. - ❌ Using
normalize=Truewhen you wanted per-row percentages. ✅normalize="index"for rows,"columns"for columns. - ❌ Reading normalized numbers as percentages. ✅ They are fractions — multiply by 100.
Interview tip
Q: How would you check whether choosing to field after the toss leads to more wins?
A: pd.crosstab(df["toss_decision"], df["result"], normalize="index") — each row then shows the win/loss share for that decision, so the two rows can be compared directly.
Key points to remember
- pd.crosstab(a, b) counts how often each combination of two categorical Series occurs.
- It is a specialised pivot table: default cell = count, and it takes Series/arrays, not column names.
- margins=True adds 'All' row and column totals (rename with margins_name).
- normalize='index' makes each row sum to 1, 'columns' each column, True/'all' the whole table.
- values + aggfunc aggregate a third column instead of counting (e.g. mean winning margin).
- A list of Series for index/columns gives MultiIndex rows/columns.
- Same counts: pivot_table(aggfunc='size', fill_value=0) or groupby([...]).size().unstack(fill_value=0).
Try it yourself
Toss decision vs result
values + aggfunc, and three equivalent ways
Practice: batting position vs dismissal
Revision Flashcards
Tap a card to flip it and see the answer.
