Grouping, pivoting and reshaping
Named aggregation, pivot tables, transform, running totals and groups too small to rank.
Create a free account Try part of it here
Why it matters
"Per store", "per month", "per customer type": almost every business question is a grouping question. In machine learning, grouped summaries become features (a customer's average order, a product's return rate), and getting them wrong leaks or distorts what a model learns. This lesson takes groupby from a single average to the full toolkit analysts use every day.
What you'll be able to do
- Summarize groups several ways at once with named aggregation
- Group by two keys, and move between long and wide tables with pivot_table and unstack
- Add group results back onto every row with transform, and build running totals
- Refuse to rank groups that are too small to trust
What it covers
- The brief
- Named aggregation
- Two keys, long and wide
- transform: a group result on every row
- Units by month and store
- Running totals
- Small groups lie
- Worked example: from lines to orders to stores
The brief
The practice shop's managers want a picture of 2024 and 2025 by store: how many orders, how much revenue, how big a typical order is, how many come from loyalty members, and how each store's share of sales moves month to month. Start by loading the order lines and applying what the cleaning lesson taught:
import pandas as pd raw = pd.read_csv("https://api.gradient.test/v1/datasets/orders.csv") orders = raw.drop_duplicates() orders = orders[orders["sku"] != "TEST-000"].copy() print(len(raw), "->", len(orders), "lines") orders["revenue"] = orders["qty"] * orders["unit_price"] orders["month"] = orders["date"].str[:7] print(orders.head(4))
Each row is an order line: one product within one order. An order with three products has three lines sharing an order_id. Keep that in mind, because "how many orders" and "how many lines" are different questions with different answers.
Named aggregation
groupby(...).agg(...) can compute several summaries in one go. Named aggregation gives each result column a name and says where it comes from, as name=(column, function):
import pandas as pd orders = pd.read_csv("https://api.gradient.test/v1/datasets/orders.csv").drop_duplicates() orders = orders[orders["sku"] != "TEST-000"].copy() orders["revenue"] = orders["qty"] * orders["unit_price"] stores = orders.groupby("store").agg( lines=("order_id", "size"), orders=("order_id", "nunique"), units=("qty", "sum"), revenue=("revenue", "sum"), ) stores["avg_order"] = (stores["revenue"] / stores["orders"]).round(2) print(stores.sort_values("revenue", ascending=False))
size counts rows in each group, and nunique counts distinct values, so orders counts each order once however many lines it has. Dividing revenue by orders, not lines, gives the average order value. Divide by lines instead and every store looks smaller, because multi-product orders get split.
The functions can be names pandas knows ("sum", "mean", "median", "max", "nunique") or any function that takes a Series and returns one value.
One habit catches a surprising number of mistakes: check that totals agree. The store revenues should add up to the revenue of the whole table, and the store order counts should add up to the number of distinct orders. If they don't, a filter dropped rows, a join duplicated them, or a group key has missing values. (By default, groupby quietly leaves out rows whose key is missing; pass dropna=False to keep them as their own group.) One line, print(stores["revenue"].sum(), orders["revenue"].sum()), is enough.
import pandas as pd df = pd.DataFrame({"store": ["a", "a", "a", "b", "b", "b"], "order": [1, 1, 2, 3, 3, 3], "qty": [1, 2, 1, 1, 1, 4]}) s = df.groupby("store").agg(lines=("order", "size"), orders=("order", "nunique"), units=("qty", "sum")) print(s.loc["a"].tolist(), s.loc["b"].tolist())
Show the answer
[3, 2, 4] [3, 1, 6]
Store a has 3 lines from orders 1 and 2, with 1 + 2 + 1 = 4 units. Store b has 3 lines, all from order 3, with 1 + 1 + 4 = 6 units.
Two keys, long and wide
Group by two columns and you get one row per pair. That's a long table: every combination stacked in one column. For reading, a wide table is often clearer, with one key down the side and the other across the top. unstack() turns long into wide, and pivot_table does the grouping and the reshaping in one call:
import pandas as pd orders = pd.read_csv("https://api.gradient.test/v1/datasets/orders.csv").drop_duplicates() orders = orders[orders["sku"] != "TEST-000"].copy() orders["revenue"] = orders["qty"] * orders["unit_price"] orders["month"] = orders["date"].str[:7] long = orders.groupby(["store", "member"])["revenue"].sum() print(long.head(4)) print(long.unstack()) wide = orders.pivot_table(index="month", columns="store", values="revenue", aggfunc="sum") print(wide.head(3).round(0))
pivot_table(index=..., columns=..., values=..., aggfunc=...) reads like a spreadsheet pivot: rows from one column, columns from another, cells holding a summary of a third. Long tables suit further grouping and most charting code. Wide tables suit reading and side-by-side comparison. You'll move between the two constantly. stack() and melt() go back from wide to long.
Nine example order lines. Grouped by store, the count of revenue counts lines, not orders: Portland shows 3 lines from only 2 orders. Group by order_id to see which orders have several lines, then try sum and mean, grouped by member.
transform: a group result on every row
agg gives one row per group. Sometimes you want the group's result written next to each row instead, to compare a row with its group. That's transform: same groups, same functions, but the answer has the same length as the original table.
import pandas as pd orders = pd.read_csv("https://api.gradient.test/v1/datasets/orders.csv").drop_duplicates() orders = orders[orders["sku"] != "TEST-000"].copy() orders["revenue"] = orders["qty"] * orders["unit_price"] orders["order_total"] = orders.groupby("order_id")["revenue"].transform("sum") orders["share_of_order"] = (orders["revenue"] / orders["order_total"]).round(2) multi = orders[orders["order_total"] > orders["revenue"]] print(multi[["order_id", "sku", "revenue", "order_total", "share_of_order"]].head(4)) orders["store_avg_line"] = orders.groupby("store")["revenue"].transform("mean") above = orders["revenue"] > orders["store_avg_line"] print(round(above.mean(), 3), "of lines are above their own store's average line")
Each line now knows its order's total, so its share is one division. The second transform puts each store's average line next to every row, so "above average *for its store*" is one comparison. Without transform you'd build the group table, then join it back onto the rows by store. That join is exactly what transform does for you.
Units by month and store
Build units, a pivot table of the total qty with months down the side and stores across the top. Then set busiest to a dictionary mapping each store to its busiest month (the month with the most units). idxmax() on a DataFrame finds the top row label of every column at once.
import pandas as pd orders = pd.read_csv("https://api.gradient.test/v1/datasets/orders.csv").drop_duplicates() orders = orders[orders["sku"] != "TEST-000"].copy() orders["month"] = orders["date"].str[:7] units = None busiest = {}
Running totals
A running total, or cumulative sum, adds each value to everything before it: year-to-date revenue, say. cumsum() does it, and after a groupby it restarts for each group:
import pandas as pd orders = pd.read_csv("https://api.gradient.test/v1/datasets/orders.csv").drop_duplicates() orders = orders[orders["sku"] != "TEST-000"].copy() orders["revenue"] = orders["qty"] * orders["unit_price"] orders["month"] = orders["date"].str[:7] orders["year"] = orders["date"].str[:4] monthly = orders.groupby(["store", "year", "month"], as_index=False)["revenue"].sum() monthly["year_to_date"] = monthly.groupby(["store", "year"])["revenue"].cumsum() print(monthly[monthly["store"] == "denver"].tail(4).round(0))
as_index=False keeps the group keys as ordinary columns, so the result is a plain long table you can keep working with. Grouping the cumsum by store and year makes every store's total start again from zero each January, which is what "year to date" means.
Small groups lie
Rank the marketplace listings by rating and look at what's on top:
import pandas as pd listings = pd.read_csv("https://api.gradient.test/v1/datasets/marketplace.csv") listings = listings.drop_duplicates(subset="listing_id") print(listings.nlargest(5, "rating")[["listing_id", "rating", "reviews"]]) trusted = listings[listings["reviews"] >= 30] print(trusted.nlargest(5, "rating")[["listing_id", "rating", "reviews"]])
The raw top five are mostly listings with a handful of reviews. One delighted buyer is enough for a 5.0. An average over a few values swings much more than an average over hundreds, so the extremes of any ranking fill up with small groups, at the top and the bottom. That's chance, not quality.
The fix is a rule set before you look: rank only groups with enough data. With groupby, compute the count alongside the average, then filter: agg(rating=("rating", "mean"), n=("rating", "size")), then [lambda t: t["n"] >= 30]. Pick the threshold by asking how many values you'd need before you'd bet on the average. Then report it, so readers know what was left out.
Worked example: from lines to orders to stores
What share of each store's orders come from loyalty members, and are members' orders bigger? The trap: the member flag repeats on every line of an order, so averaging lines would count big orders several times.
import pandas as pd orders = pd.read_csv("https://api.gradient.test/v1/datasets/orders.csv").drop_duplicates() orders = orders[orders["sku"] != "TEST-000"].copy() orders["revenue"] = orders["qty"] * orders["unit_price"] per_order = orders.groupby("order_id").agg( store=("store", "first"), member=("member", "first"), total=("revenue", "sum"), ) print(len(per_order), "orders") summary = per_order.groupby("store").agg(member_share=("member", "mean"), avg_order=("total", "mean")) print(summary.round(3)) print(per_order.pivot_table(index="store", columns="member", values="total", aggfunc="mean").round(2))
The first groupby collapses lines into orders: one row per order, with its store, its member flag (first is safe because every line of an order has the same one) and its total. Only then does the second groupby summarize orders per store. The mean of a 0/1 column is the share of 1s, so member_share is the fraction of orders from members. The pivot table shows average order value split by membership in every store. Two-step aggregation, first to the right unit, then to the summary, is the pattern to use whenever a table's rows are smaller than the thing you're counting.
Practice what you just read
- 7 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Order lines repeat the customer's member flag on every line. Why is the mean of the flag across lines the wrong "share of orders from members"?