pivot_table and crosstab
The same aggregation, with the second key across the top instead of down the side.
pivot_tableindex/columns/valuesaggfuncfill_valuemarginscrosstabWatch it happen
Play it through, or step back and forth yourself.
dfdf.groupby(["city", "item"])["cups"].sum()Group by city and item and you get one row per combination, with both keys stacked into the index. Correct, complete, and hard to read — you can't compare cities at a glance.
The idea
Group by two keys and you get one row per combination, with both keys stacked in the index:
orders.groupby(["city", "item"])["cups"].sum()Correct, complete, and awkward to read — you can't compare cities at a glance because they're spread down the page. pivot_table runs the same aggregation and lays the second key across the top instead.
orders.pivot_table(
index="city", # down the side
columns="item", # across the top
values="cups", # what gets aggregated
aggfunc="sum", # how
)Same numbers, in a grid you can actually read. If you know groupby, you already know pivot_table — only the layout is new.

Four things worth knowing
aggfunc defaults to "mean". Not sum. This is the single most common surprise, and it produces plausible-looking numbers that are quietly wrong. Say what you mean, every time.
Empty cells become NaN. A combination that never occurred — Pune never sold elaichi — has nothing to aggregate. fill_value=0 is usually right, and it also stops the column being promoted to float.
margins=True adds totals as an All row and column.
Several keys work. index=["city", "item"] or values=["cups", "revenue"] both give you a MultiIndex — the subject of the next module.
crosstab
When you just want to count combinations, pd.crosstab is the shorthand:
pd.crosstab(orders["city"], orders["item"]) # counts
pd.crosstab(orders["city"], orders["item"], normalize="index") # row proportionsIt takes Series rather than a frame and a column name, which reads oddly at first but means you can cross-tabulate things that aren't in the same table.
pivot versus pivot_table
There's also a plain .pivot(), and the difference catches people out:
df.pivot(index=, columns=, values=) # reshape ONLY — raises on duplicates
df.pivot_table(...) # aggregates — duplicates are finepivot assumes each combination appears exactly once and raises ValueError: Index contains duplicate entries if not. Since our table has two Delhi orders on the same day, it would fail here. Use pivot_table unless you're certain — it does the same job and handles the duplicate case by aggregating.
Getting back
A pivot table is wide. .stack() folds the columns back down into the index, and .melt() returns to fully long form — both in the next module. A pivot table is for reading; long form is for computing.
Practice
Write it yourself. The answer is there when you want it.
Putting the kettle on…
Starting up…
Write it yourself
not gradedBuild a pivot table of cups with city down the side and item across the top, summed, with the empty combinations filled with zero, and print it. Print it again with the margins turned on. Finish with crosstab over the same two columns, which counts rather than sums.
Your turn
4 exercises. Write the code yourself, then press Check — a nudge and the answer are there if you want them.
Build a pivot table of total cups with city down the side and item across the top.
The same table, but with the empty combinations shown as 0 instead of NaN.
Now the average rating per city and item, with city down the side.
Return a count of how many orders each city placed for each item, using pd.crosstab.
