How do I build a frequency table of two categories in pandas?
Short answer
Use crosstab to count how often each combination of two categories occurs, laid out as a grid. Add normalize for proportions and margins for row and column totals.
The short version
Use pd.crosstab(rows, columns) to count how often each combination of two categorical columns occurs, arranged as a grid with one category down the side and the other across the top. Add normalize=True for proportions instead of counts, and margins=True to include row and column totals.
A worked example
import pandas as pd
df = pd.DataFrame({
"city": ["Leeds", "Leeds", "Bath"],
"size": ["S", "L", "S"],
})
print(pd.crosstab(df["city"], df["size"]))
size L S
city
Bath 0 1
Leeds 1 1
Each cell counts the rows with that city and size combination. Bath has one small and no large; Leeds has one of each.
Proportions instead of counts
normalize turns counts into shares. normalize="index" makes each row sum to 1, "columns" makes each column sum to 1, and True divides by the grand total.
pd.crosstab(df["city"], df["size"], normalize="index")
This answers questions like "what proportion of Leeds rows are large", which raw counts do not show directly.
Adding totals
margins=True adds an "All" row and column with the totals, handy for a quick sanity check that the parts sum correctly.
pd.crosstab(df["city"], df["size"], margins=True)
crosstab versus value_counts versus pivot_table
value_counts counts one column. crosstab is the natural tool for counting the interaction of two columns. pivot_table is the more general tool that can do the same and more, aggregating a third value column with any function; crosstab is really a convenient special case of it for counting. Reach for crosstab when you specifically want a count grid of two categories, and pivot_table when you need to summarise a numeric column across them.
Aggregating a value instead of counting
If you want to summarise a numeric column rather than count rows, pass values and aggfunc, which pushes crosstab towards pivot_table territory.
pd.crosstab(df["city"], df["size"], values=df["amount"], aggfunc="mean")
A note on missing combinations
Combinations that never occur show as 0, not as absent rows, which is usually what you want for a complete table. If a category value is itself missing (NaN), it is dropped by default; pass dropna=False to keep it as its own row or column.