The idea
Almost every report is "something per something": shipping per channel, revenue per order, products per category. groupby answers all of them with the same three-step pattern, called split-apply-combine.
Split the rows into groups by the value of one column (or several). Apply a calculation to each group on its own, one that reduces it to a single value. Combine those values into a new table with one row per group.
Figure 1
orders.groupby('channel')['shipping_fee'].sum(), one group at a time
| order_id | channel | shipping_fee |
|---|---|---|
| 1003 | marketplace | 4.99 |
| 1005 | web | 0.00 |
| 1007 | marketplace | 7.65 |
| 1009 | app | 0.00 |
| 1010 | web | 6.95 |
| 1012 | app | 0.00 |
| channel | shipping_fee |
|---|---|
| marketplace | 12.64 |
| web | 6.95 |
| app | 0.00 |
- Six orders with a channel and a shipping fee. The question: how much shipping, per channel?
- Split.
groupby('channel')sorts the rows into piles by channel value. Marketplace gets orders 1003 and 1007. The other rows sit in their own piles for now. - Apply.
.sum()reduces the marketplace pile to one number: 4.99 + 7.65 = 12.64. - The same happens for web (0.00 + 6.95) and for app (0.00 + 0.00). Each pile is processed on its own and never sees the others.
- Combine. One row per group, with the group value as the label of the row. Six rows became three. The result is a new table, ready for sorting or a chart.
The picture to keep is piles. groupby('channel') does not calculate anything. It sorts the rows into piles, one per channel, and remembers which pile each row is in. Only the next call, .sum(), .mean() or .size(), does arithmetic, and it does it inside each pile separately.
Why it exists
In plain Python this is the counting loop from chapter 2 with a dictionary of lists, written by hand for every question: make an empty dictionary, run through every row, add it to its pile, then loop over the piles. In SQL it is GROUP BY. In a spreadsheet it is a pivot table or a SUMIF per group. The idea is always the same. In pandas it is one line, and the result is a DataFrame you can sort, filter, chart or merge with another table.
The other reason is volume. The store has 30,000 order lines. Nobody can scan that for the biggest baskets, and nobody should have to: the whole job is "give each order a total, then look at the totals".
How it works under the hood
What the groups are made from
groupby takes one column name, or a list of them. With two keys, groupby(['channel', 'status']), a pile exists for every combination that really occurs in the data: marketplace and cancelled, web and completed, and so on. A combination that never happens simply has no pile. The key values become the index of the result, so a two-key result has two index levels. reset_index() turns them back into ordinary columns, which is usually what you want before sorting or merging.
What an aggregation is
An aggregation reduces many values to one: sum, mean, min, max, size, count, nunique. The choice decides what the question means. size() counts the rows in each pile, including those with blanks. count() counts the values that are present in one column. sum() adds them. For "how many orders per channel" use size. For "how many units sold per product" use sum on quantity, not count, because count returns the number of lines.
New column first, then group
Group the right thing. The store keeps quantity, unit price and discount on each line, not the money, so the first level of the chapter is to compute line_revenue with one vectorised expression, exactly as in the Foundations. Only then does grouping make sense.
Figure 2
New column first, then group: revenue per order
| order_id | quantity | unit_price | discount_pct | line_revenue |
|---|---|---|---|---|
| 1002 | 4 | 11.99 | 0.0 | 47.96 |
| 1002 | 1 | 149.00 | 0.0 | 149.00 |
| 1002 | 2 | 19.99 | 0.0 | 39.98 |
| 1002 | 2 | 25.99 | 0.0 | 51.98 |
| 1003 | 1 | 92.99 | 10.0 | 83.69 |
| 1003 | 1 | 349.00 | 10.0 | 314.10 |
| order_id | revenue | n_lines |
|---|---|---|
| 1002 | 288.92 | 4 |
| 1003 | 397.79 | 2 |
- Harbor & Pine stores quantity, price and discount on each line, but not the money.
line_revenuedoes not exist in the data yet, so a sum of it cannot either. - Compute it first, for every row at once:
quantity * unit_price * (1 - discount_pct / 100), rounded to cents. Now each line carries its own revenue. - Then
groupby('order_id')splits the lines into one pile per order, and an aggregation reduces each pile. Order 1002 has four lines, so four revenues add up to 288.92. - Order 1003 has two lines: 83.69 + 314.10 = 397.79. The same step also gave
n_lines, which counts the rows of each pile: 4 and 2.
Notice the grain. The source table has one row per order line, and the result has one row per order. Group by a different key and you get a different grain: by product_id gives revenue per product, by channel would need the order's channel first, and that column is in orders, not order_items. When a key lives in another table you merge first, which is chapter 7.
Named aggregation
.agg(revenue=('line_revenue', 'sum'), n_lines=('order_item_id', 'size')) computes several aggregations at once and names each result column as it is made. The shape is new_name=(column, function). You get exactly the column names you want, no multi-level headers to untangle, and the code documents itself: anyone can read what revenue is.
Filtering after aggregating
Filtering the rows before grouping and filtering the groups after are different questions. "Orders from the web" is a row filter, applied first. "Departments with at least seven people" is a group filter: you cannot know a department's size until you have counted it, so you aggregate first and then filter the summary table with a mask, as in chapter 5.
Figure 3
A groupby as a chain of steps
-
order_items.assign(line_revenue=...)a column to add up -
.groupby('order_id')split -
.agg(revenue=('line_revenue', 'sum'))apply and combine -
[lambda t: t['revenue'] > 1000]filter the groups -
.sort_values('revenue')order the result
- Prepare the data: make sure the column you want to aggregate exists, row by row.
groupbyon its own does no arithmetic. It only remembers which rows belong together.aggreduces each group to one row. Named aggregation,revenue=('line_revenue', 'sum'), names the result column and says which column and which function.- The result is an ordinary DataFrame with one row per order. Filtering it now keeps orders, not lines. This is how you ask for orders worth over $1,000.
- Then it can be sorted like any other table to find the biggest baskets.
Read a chain like this from the top: each step takes the table the previous one made. If a number is wrong, run the chain one step at a time and look at the table after each, to find the step where it went wrong.
A check before you trust the table
Two quick checks catch most groupby mistakes. First, the total of the result should match the total of the source: result['revenue'].sum() against order_items['line_revenue'].sum(). If the numbers differ, rows were lost, usually because the key has missing values, which groupby drops by default. Second, the number of rows in the result should equal nunique() of the key. Compare, for example, the 12,000 orders in the store with the rows you get from grouping lines by order_id. A gap tells you something is off before anyone else notices.
Common misconceptions
"groupby calculates the average for me"
groupby alone returns an object that has not computed anything, and printing it shows something like <DataFrameGroupBy object>. The calculation comes from the method you call next. This also means the same groups can answer many questions: mean, sum and size are three different reductions of identical piles.
"count() and sum() are interchangeable"
count() returns how many values there are, and sum() adds them up. On a column of quantities, count() gives the number of lines and sum() gives units sold. A bestseller list built with count ranks products by how many orders mention them, not how many were sold, and it looks completely believable. State the question in words first (orders, lines, units, or money) and then pick the function.
"The group column is just another column in the result"
By default the grouping key becomes the index of the result, not a column. That is why a groupby result often seems to be "missing" its first column, and why result['channel'] raises a KeyError. Call reset_index() or pass as_index=False when you want the key as a plain column.
Check yourself
One question. Answering it ticks this lesson off on the map; it never locks a level.
Not quite. They are different reductions of the same group. One counts rows, the other adds the values.Try another answer.
Right. count() counts how many values are present in the group, here one per line. sum() adds the quantities. Picking count when you meant sum is a classic wrong bestseller list.
Not quite. Reversed. count() never adds values, it counts them.Try another answer.
Not quite. count() does not look for different values (that is nunique()), and sum() of quantity is units, not money.Try another answer.
Lesson done: it is ticked on the map.