Wide and long: pivot_table and melt
In short: pivot_table makes a wide table with a column for each category, and melt brings back the long one with a row for each fact; you pick the shape by the question.
Problem
“Give it to me by month and category”
The manager wants the revenue of the year in one table: months down, categories across, like in Excel. You know how: join the lines with the orders, the products and the categories (merge) and group by two keys. Cancelled orders are not revenue, so you remove them. The gift card has no category and gets a name of its own, or it would drop out of the groups:
The numbers are right, but there are 84 rows: 12 months times 7 categories, one under another. To compare January with July, you search the list with your eyes. The table the manager wants is 12 rows and 7 columns with the same 84 numbers.
The month here is its number: 1 is January 2026, and 11 and 12 are November and December 2025.
The idea
Long and wide
The same numbers can sit in two shapes:
- Long: a row for every fact. The columns are
month,categoryandrevenue, and every month–category pair is a separate row. - Wide: a row for every month and a column for every category. The category is no longer a column: its values have become column names.
From long to wide with the result of groupby takes one line: unstack() takes the last level of the index (the category) and turns it into columns:
The shape is (12, 7): 12 months and 7 categories. January: €593 kitchen, €2,038 dairy, €1,225 drinks.
The idea
Straight to wide: pivot_table
pivot_table does the grouping and the layout at once. You tell it four things:
index: what goes in the rows (the month);columns: what becomes columns (the category);values: which column it works with (the revenue);aggfunc: how it works it out ('sum').
Now you can read it at a glance. December is the peak: the kitchen makes €3,241, over 4 times an ordinary month, and the gift card €2,160. Summer brings drinks and fruit: drinks are €1,756 in July against €1,225 in January. September is weak almost everywhere. Why, we will find out in the project.
More precisely, if you are curious
There is also pivot without “table”: it only lays out and does not add up. If a month–category pair appears in more than one row, pivot stops with an error, while pivot_table combines them with aggfunc. For raw lines you need pivot_table.
Try it
Units by category
How many units did each category sell each month? Put a table with the categories in the rows and the months in the columns in units.
Solution
import pandas as pd
orders = pd.read_csv('year_orders.csv').rename(columns={'id': 'order_id'})
products = pd.read_csv('products.csv').rename(columns={'id': 'product_id'})
categories = pd.read_csv('categories.csv').rename(columns={'id': 'category_id', 'name': 'category'})
orders = orders[orders['status'] != 'cancelled']
orders['month'] = pd.to_datetime(orders['ordered_at']).dt.month
lines = pd.read_csv('year_items.csv').merge(orders[['order_id', 'month']], on='order_id')
lines = lines.merge(products[['product_id', 'category_id']], on='product_id', how='left')
lines = lines.merge(categories[['category_id', 'category']], on='category_id', how='left')
lines['category'] = lines['category'].fillna('Gift card')
lines['revenue'] = lines['quantity'] * lines['unit_price']
units = lines.pivot_table(index='category', columns='month', values='quantity', aggfunc='sum')
unitslines.pivot_table(index='category', columns='month', values='quantity', aggfunc='sum') gives 7 rows and 12 columns: dairy sells 1,159 units in December. Without aggfunc the table comes out the same shape, but the cells hold the average units per line, about 1 to 1.6, because pivot_table works out mean by default. It looks like an answer, and that is what makes it dangerous.
The idea
Empty cells: fill_value
In the wide table every pair has a cell, even when the data has no such pair. Here is how much each customer bought from each category in the 60 orders:
35 cells are NaN. Customer 3 bought no bread, cleaning or drinks. Here empty means “zero purchases”, not “we do not know”. So it is right to replace it with zero: fill_value=0.
This is the case from the lesson on summaries where 0 is the right replacement: the zero really means zero. If the cell were empty because a report was missing, the zero would lie.
The idea
The totals: margins
margins=True adds an All row and column with the totals. Two of the categories and the total are shown here:
The All column is the revenue of the month, and the All row is the revenue of the category for the year: €11,304 kitchen, €17,811 drinks. In the corner is the total: €111,186.20. September is €7,129, less than August (€8,813) and October (€9,920).
The idea
Back to long: melt
The manager sends the plan for the next three months. The table is from Excel and it is wide: a row per category and a column per month.
A wide table is good for reading, but not for working with. The month is not a column, it is in the column names, and there is nothing to group by. melt melts it back into a long one: id_vars is the column that stays, var_name names the column with the old column names, and value_name the column with the numbers:
7 rows and 4 columns become 21 rows and 3 columns: every number of the plan is a separate row. Now the month is a column, and groupby works:
The plan for December is €16,650, and for November €9,700.
Try it
Which shape for what
Exercise
The report for the manager
Make the report from the first card: report with the revenue by month (rows) and category (columns), with the All totals. Then put in dec_share what part of the year's revenue December is.
Solution
import pandas as pd
orders = pd.read_csv('year_orders.csv').rename(columns={'id': 'order_id'})
products = pd.read_csv('products.csv').rename(columns={'id': 'product_id'})
categories = pd.read_csv('categories.csv').rename(columns={'id': 'category_id', 'name': 'category'})
orders = orders[orders['status'] != 'cancelled']
orders['month'] = pd.to_datetime(orders['ordered_at']).dt.month
lines = pd.read_csv('year_items.csv').merge(orders[['order_id', 'month']], on='order_id')
lines = lines.merge(products[['product_id', 'category_id']], on='product_id', how='left')
lines = lines.merge(categories[['category_id', 'category']], on='category_id', how='left')
lines['category'] = lines['category'].fillna('Gift card')
lines['revenue'] = lines['quantity'] * lines['unit_price']
report = lines.pivot_table(index='month', columns='category', values='revenue', aggfunc='sum', margins=True)
dec_share = report.loc[12, 'All'] / report.loc['All', 'All']
print(round(dec_share * 100, 1))lines.pivot_table(index='month', columns='category', values='revenue', aggfunc='sum', margins=True) gives the table with the totals. report.loc[12, 'All'] / report.loc['All', 'All'] is about 0.143: December brings in 14.3% of the year's revenue, while it is one month of 12. Without aggfunc the corner would hold the average line, not the revenue.
What is the plan for the three months in total for each category? Make plan long (plan_long with the columns category, month and plan) and put the total for each category in per_category.
Solution
import pandas as pd
plan = pd.read_csv('plan.csv')
plan_long = plan.melt(id_vars='category', var_name='month', value_name='plan')
per_category = plan_long.groupby('category')['plan'].sum()
print(per_category)plan.melt(id_vars='category', var_name='month', value_name='plan') gives 21 rows, and plan_long.groupby('category')['plan'].sum() gives one number per category: kitchen is €5,050, and dairy €8,700. Without id_vars the category column melts into the rows too, and the table has 28 rows, with category names among the numbers.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: New columns · Types and missing values · Groups: groupby · Joining: merge
- Continues in: The first chart · Which chart for which question · Honest charts · A/B test
- Tasks on this topic: Wrangling data