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

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" totals
import 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    6

2 Percentages with normalize=

Raw counts can mislead when groups are different sizes. normalize rescales the table into proportions:

pd.crosstab(df["dept"], df["level"], normalize="index")
# each dept row now shows the share of Jr vs Sr within that dept
import 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.166667

3 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 cell
import 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.0
import 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.

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

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.