A frequency table of two columns, in one call.
How often does each combination of these two columns occur? You can get there with groupby and a reshape, or with pivot_table and a counting aggfunc. pd.crosstab does it directly, because counting combinations is the only thing it does.
The one structural quirk to absorb first: it is a top-level function, not a method, and it takes series rather than column names. So it does not sit in a method chain by itself — you reach it through pipe, which is how Reuven writes it in the solutions below.
Official documentation: pandas.crosstab.
The arguments that earn their keep
pd.crosstab(df['a'], df['b'], # the two series to cross
normalize='index', # 'index', 'columns', 'all', or False
margins=True, # add row and column totals
values=df['c'], # summarize this instead of counting
aggfunc='mean') # how — values and aggfunc travel together
normalize is the one that earns its keep most often. 'index' makes each row sum to 1, 'columns' makes each column sum to 1, and 'all' divides by the grand total — three different questions that people routinely mix up. values and aggfunc must be passed together; either alone raises a ValueError that says so.
A worked example, on real data
The Atlas of Surveillance behind Bamboo Weekly #182 records one row per US law-enforcement agency per surveillance technology.
import pandas as pd
url = 'https://www.bambooweekly.com/content/files/2026/08/bw-182-surveillance.csv'
df = pd.read_csv(url, usecols=['State', 'Type of LEA', 'Technology'])
techs = ['Drones', 'Face Recognition', 'Fusion Center',
'Gunshot Detection', 'Predictive Policing']
sub = df.loc[df['Type of LEA'].isin(['Police', 'Sheriff'])
& df['Technology'].isin(techs)]
pd.crosstab(sub['Type of LEA'], sub['Technology'])
Technology Drones Face Recognition Fusion Center Gunshot Detection Predictive Policing
Type of LEA
Police 1129 692 1 231 92
Sheriff 621 183 0 15 104
Raw counts favor police departments simply because there are more of them. normalize='index' asks the question people actually mean — of everything this kind of agency deploys, what share is each technology?
pd.crosstab(sub['Type of LEA'], sub['Technology'], normalize='index').round(3)
Technology Drones Face Recognition Fusion Center Gunshot Detection Predictive Policing
Type of LEA
Police 0.526 0.323 0.0 0.108 0.043
Sheriff 0.673 0.198 0.0 0.016 0.113
Sheriffs lean much harder on drones — 67 percent of their deployments here against 53 percent for police — which fits, since sheriffs cover rural counties where flying beats driving. Gunshot detection runs the other way, close to seven times more common among police.
margins=True puts the denominators back where you can see them:
pd.crosstab(sub['Type of LEA'], sub['Technology'], margins=True)
Technology Drones Face Recognition Fusion Center Gunshot Detection Predictive Policing All
Type of LEA
Police 1129 692 1 231 92 2145
Sheriff 621 183 0 15 104 923
All 1750 875 1 246 196 3068
When it beats groupby().size().unstack()
Both produce the same table, so this is a real choice rather than a matter of taste. Here is the difference, and it is not subtle:
sub.groupby(['Type of LEA', 'Technology']).size().unstack()
Technology Drones Face Recognition Fusion Center Gunshot Detection Predictive Policing
Type of LEA
Police 1129.0 692.0 1.0 231.0 92.0
Sheriff 621.0 183.0 NaN 15.0 104.0
No sheriff runs a fusion center, so groupby produces no group, unstack invents a NaN, and every count in the table becomes a float to accommodate it. crosstab writes a 0 there and stays int64, because it knows it is counting, and the count of nothing is zero rather than unknown.
So: reach for crosstab when you want counts of two columns and you want empty combinations to read as zero. Reach for groupby when the aggregation is more than counting, when you need the long form to keep working with, or when the absent combination genuinely means "not measured" rather than "none." Reuven makes the same comparison against pivot_table in the roller-coaster solution below, and lands in the same place.
Three mistakes people make
Passing column names instead of series. pd.crosstab('Type of LEA', 'Technology') gives ValueError: If using all scalar values, you must pass an index, which tells you nothing about your actual error. It never saw the data frame. Pass df['Type of LEA'], or .pipe() a lambda that does.
Normalizing along the wrong axis. normalize='index' and normalize='columns' both return plausible-looking proportions, and only one answers your question. Check which direction sums to 1 before you quote a percentage out loud.
Assuming a zero means nobody does it. These are counts of rows in one file. A 0 in the fusion-center column means no sheriff's office appears in this dataset with that technology — not that none exists.
Where it shows up in Bamboo Weekly
Bamboo Weekly #91: Roller coasters builds the same table twice, once with pivot_table(aggfunc='count') and once with crosstab, and compares them line by line. It is also the clearest demonstration of the pipe idiom for calling a top-level function mid-chain.
Bamboo Weekly #173: IPOs crosses two boolean columns — does the ticker end in U, was the offer priced at exactly $10 — with normalize='index', and finds that 98 percent of U-suffixed tickers priced at $10 against 7 percent of the rest. A correlation had already hinted at it; the crosstab is what made it legible.
Practice it
Work through a crosstab exercise, with instant feedback and no signup required: practice.lernerpython.com/bamboo-weekly/crosstab/
Go deeper
value_counts is the one-column version of this idea, and it accepts a list of columns for the long form. pivot_table is the method to use once the cells should hold something other than counts, and groupby is underneath all of them.
More Pandas videos on Python and Pandas with Reuven Lerner.
Bamboo Weekly is the practice. If you want the structured version — full courses with downloadable Jupyter notebooks, plus live Pandas office hours when you get stuck — that is LernerPython+Data. A paid Bamboo Weekly subscription is included with it.
Related methods
.groupby()— when you want something other than a count, or more than two keys.pivot_table()— when the cells should hold an aggregate rather than a frequency
See it on real data
Below are the 2 Bamboo Weekly exercises that use crosstab on real-world data — try each one, then study the worked solution.
Part of the Pandas Methods Index. See also practice by skill.