Cross-Tabulation (crosstab)
Reviewed & published by Brayan K
A cross-tabulation counts how often each combination of two categories occurs, laying one variable down the rows and the other across the columns to reveal their relationship at a glance.
Part of the free Pandas course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
You'll learn pd.crosstab(rows, cols), percentages with normalize=, totals with margins=True, and value summaries with aggfunc.
What You'll Learn in This Lesson
- Count combinations with pd.crosstab(rows, cols)
- Add row/column totals with margins=True
- Turn counts into row/column percentages
- Use normalize=True / 'index' / 'columns'
- Summarise a value with values= and aggfunc=
- When to reach for crosstab vs pivot_table
1 Counting Combinations (and Adding Totals)
The simplest call pd.crosstab(df["dept"], df["level"]) builds a frequency table: each cell is the number of rows that have that dept and that level. Add margins=True to tack on an All row and column with the totals.
import pandas as pd
pd.crosstab(df["dept"], df["level"]) # counts
pd.crosstab(df["dept"], df["level"], margins=True) # counts + "All" totalsimport pandas as pd
df = pd.DataFrame({
"dept": ["Sales", "Sales", "Eng", "Eng", "Eng", "Sales"],
"level": ["Jr", "Sr", "Jr", "Sr", "Jr", "Jr"],
"salary": [50, 80, 60, 95, 62, 48],
})
# Frequency of each dept/level combination
print(pd.crosstab(df["dept"], df["level"]))
# Same table with row/column totals
print(pd.crosstab(df["dept"], df["level"], margins=True))
# ✅ Expected output:
# level Jr Sr
# dept
# Eng 2 1
# Sales 2 1
# level Jr Sr All
# dept
# Eng 2 1 3
# Sales 2 1 3
# All 4 2 62 Percentages with normalize=
Raw counts can mislead when groups are different sizes. normalize rescales the table into proportions:
- normalize=True — every cell divided by the grand total (whole table sums to 1).
- normalize="index" — each row sums to 1 (row percentages).
- normalize="columns" — each column sums to 1 (column percentages).
pd.crosstab(df["dept"], df["level"], normalize="index")
# each dept row now shows the share of Jr vs Sr within that deptimport pandas as pd
df = pd.DataFrame({
"dept": ["Sales", "Sales", "Eng", "Eng", "Eng", "Sales"],
"level": ["Jr", "Sr", "Jr", "Sr", "Jr", "Jr"],
})
# Row percentages: within each dept, what share is Jr vs Sr?
print(pd.crosstab(df["dept"], df["level"], normalize="index"))
# Whole-table proportions (every cell / grand total)
print(pd.crosstab(df["dept"], df["level"], normalize=True))
# ✅ Expected output:
# level Jr Sr
# dept
# Eng 0.666667 0.333333
# Sales 0.666667 0.333333
# level Jr Sr
# dept
# Eng 0.333333 0.166667
# Sales 0.333333 0.1666673 Summarising a Value with values= and aggfunc=
A crosstab does not have to count. Supply values= with a numeric Series and aggfunc= with how to summarise it, and each cell becomes that statistic for the combination — for example the average salary of each dept/level pair instead of a head count.
pd.crosstab(
df["dept"], df["level"],
values=df["salary"], aggfunc="mean",
) # mean salary per dept/level cellimport pandas as pd
df = pd.DataFrame({
"dept": ["Sales", "Sales", "Eng", "Eng", "Eng", "Sales"],
"level": ["Jr", "Sr", "Jr", "Sr", "Jr", "Jr"],
"salary": [50, 80, 60, 95, 62, 48],
})
# Average salary per dept/level combination
print(pd.crosstab(df["dept"], df["level"],
values=df["salary"], aggfunc="mean"))
# ✅ Expected output:
# level Jr Sr
# dept
# Eng 61.0 95.0
# Sales 49.0 80.0import pandas as pd
survey = pd.DataFrame({
"region": ["N", "N", "S", "S", "S", "N"],
"choice": ["Yes", "No", "Yes", "Yes", "No", "Yes"],
})
# 🎯 YOUR TURN:
# Count Yes/No answers by region AND show the row/column totals.
# Fill in the function name and the keyword argument.
print(pd.____(survey["region"], survey["choice"], ____=True))
# Expected output:
# choice No Yes All
# region
# N 1 2 3
# S 1 2 3
# All 2 4 6! Common Errors (And How to Fix Them)
❌ Passing column names instead of Series
crosstab wants the actual data, not strings:
pd.crosstab("dept", "level") # ❌ treats the strings as data✅ Fix: pass the Series:
pd.crosstab(df["dept"], df["level"]) # ✅pd.crosstab(df["dept"], df["level"], values=df["salary"]) # ❌ error✅ Fix: always give both:
pd.crosstab(df["dept"], df["level"],
values=df["salary"], aggfunc="mean") # ✅🎯 Mini Challenge: Store Category Mix
Two stores log which category each sale belongs to. Compare their mix.
- Count sales per store and category
- Convert to row shares so each store's mix sums to 1
import pandas as pd
df = pd.DataFrame({
"store": ["A", "A", "A", "B", "B", "B", "B"],
"category": ["food", "toys", "food", "toys", "toys", "food", "tech"],
})
# 1) raw counts
print(pd.crosstab(df["store"], df["category"]))
# Expected:
# category food tech toys
# store
# A 2 0 1
# B 1 1 2
# 2) each store's category mix (row shares)
print(pd.crosstab(df["store"], df["category"], normalize="index"))
# Expected:
# category food tech toys
# store
# A 0.666667 0.00 0.333333
# B 0.250000 0.25 0.500000
# ✅ Expected output:
# category food tech toys
# store
# A 2 0 1
# B 1 1 2
# category food tech toys
# store
# A 0.666667 0.00 0.333333
# B 0.250000 0.25 0.500000❓ Frequently Asked Questions
Lesson complete — you can profile categories fast!
You can count combinations with pd.crosstab, add totals with margins=True, switch to percentages with normalize, and summarise values with values= + aggfunc=.
Practice quiz
What does a plain pd.crosstab(rows, cols) compute in each cell?
- The mean of a value
- The sum of a value
- The count of rows with that row/column combination
- A correlation
Answer: The count of rows with that row/column combination. By default crosstab builds a frequency table: each cell counts the matching rows.
In pd.crosstab(a, b), what does the first argument become?
- The rows (index)
- The columns
- The values
- The totals
Answer: The rows (index). The first argument forms the rows (index) and the second forms the columns.
What must you pass to crosstab as the row and column arguments?
- Column name strings
- A dictionary
- A list of bins
- The actual Series
Answer: The actual Series. crosstab takes the actual Series, not column-name strings; passing strings treats them as data.
What does margins=True add?
- Percentages
- An 'All' row and column with the totals
- A new value column
- Sorting
Answer: An 'All' row and column with the totals. margins=True appends an 'All' row and column holding the marginal totals.
What does normalize=True do?
- Each cell is divided by the grand total (whole table sums to 1)
- Each row sums to 1
- Each column sums to 1
- Nothing changes
Answer: Each cell is divided by the grand total (whole table sums to 1). normalize=True divides every cell by the grand total so the whole table sums to 1.
Which normalize value makes each row sum to 1 (row percentages)?
- normalize=True
- normalize='columns'
- normalize='index'
- normalize='rows'
Answer: normalize='index'. normalize='index' rescales so each row's values sum to 1.
To compute the average salary per dept/level cell instead of a count, you pass...
- margins=True
- values= and aggfunc= together
- normalize='index'
- keys=
Answer: values= and aggfunc= together. values= supplies the numeric Series and aggfunc= says how to summarise it; both are required together.
What happens if you pass values= without aggfunc=?
- It defaults to count
- It averages automatically
- It returns the values unchanged
- pandas raises an error
Answer: pandas raises an error. values= and aggfunc= must travel together; one without the other raises an error.
For a 6-row frame with depts Eng/Sales each appearing across Jr and Sr, what is the 'All' total cell with margins=True?
- 3
- 6
- 2
- 4
Answer: 6. The bottom-right 'All' cell is the grand total of rows, which is 6.
How does crosstab differ from pivot_table?
- They are identical
- pivot_table only counts
- crosstab defaults to counting and accepts plain Series; pivot_table works from existing columns and defaults to averaging
- crosstab cannot use aggfunc
Answer: crosstab defaults to counting and accepts plain Series; pivot_table works from existing columns and defaults to averaging. crosstab is a frequency-focused wrapper taking Series; pivot_table works from DataFrame columns and averages by default.
Continue this course
- Previous: Exploding Lists into Rows (explode)
- Next: Reading & Writing SQL — Move data between pandas and databases with read_sql and to_sql
- Quick reference: Pandas cheat sheet
Frequently asked questions
What is the difference between crosstab and pivot_table?
They are close cousins. pd.crosstab() is a convenience wrapper that defaults to counting the frequency of each row/column combination and accepts plain Series as arguments. pivot_table() works from columns of an existing DataFrame and defaults to averaging. For simple frequency tables crosstab is shorter; for richer aggregations on existing columns pivot_table is more natural.
What does normalize do?
normalize converts counts to proportions. normalize=True divides by the grand total (every cell sums to 1 across the whole table), normalize='index' makes each row sum to 1 (row percentages), and normalize='columns' makes each column sum to 1 (column percentages).
How do I add row and column totals?
Pass margins=True. crosstab then appends an 'All' row and an 'All' column holding the totals, so you can read both the per-cell counts and the marginal totals in one table. You can rename the label with margins_name.
How do I summarise a value instead of just counting?
Pass values= (the numeric Series to summarise) together with aggfunc= (how to summarise it). For example pd.crosstab(df['dept'], df['level'], values=df['salary'], aggfunc='mean') gives the average salary for each dept/level combination instead of a head count.