EduFlow

Section 4 · Analysis: Group, Join, Reshape

Reshaping with pivot_table and melt

18 min3 min readPython exercise

Lesson at a glance

Reshape data between long and wide formats - pivot_table for cross-tab summaries, crosstab for counts and rates, and melt to turn spreadsheet-style wide tables back into analysable long ones.

  1. 1pivot_table: summarise and reshape at once
  2. 2crosstab: counts and rates
  3. 3melt: from wide back to long

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

A manager does not want 3,991 rows. They want a grid: channels down the side, quarters across the top, revenue in the cells. The finance team sends targets the same way, one column per quarter. Your analysis, meanwhile, works best on long tables with one row per observation, because groupby, merge and filtering all expect that shape. Reshaping moves data between the two forms, and it is the last core skill before you start answering questions in earnest.

On the left, a long table with columns quarter, channel and revenue has six rows. An arrow labelled pivot_table points to a wide table with quarters as rows and channels web and app as columns. A return arrow labelled melt points back. long quarter channel revenue Q1 web 27,868 Q1 app 16,814 Q2 web 30,110 Q2 app 15,840 one row per observation pivot_table melt wide quarter web app Q1 27,868 16,814 Q2 30,110 15,840 one column per channel
Long and wide hold the same data in different shapes

pivot_table: summarise and reshape at once

Start from the sales table you built in Lesson 4.2 (here rebuilt in a few lines) and ask for revenue by channel and year:

Python
import pandas as pd

items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
items["revenue"] = items["quantity"] * items["unit_price"] * (1 - items["discount"])
sales = items.merge(orders, on="order_id", how="left", validate="many_to_one")
sales = sales[sales["status"] != "cancelled"]
sales = sales.assign(year=sales["order_date"].dt.year, quarter=sales["order_date"].dt.quarter)

table = sales.pivot_table(index="channel", columns="year", values="revenue",
                          aggfunc="sum", margins=True, margins_name="Total")
print(table.round(0))

Every argument maps to a spreadsheet pivot: index is the rows, columns the columns, values the field, aggfunc the summary. margins=True adds the totals row and column, and they confirm the result: 446,666 dollars overall, the revenue definition from the last lesson. Revenue grew in every channel from 2024 to 2025, and the web roughly tripled.

pivot_table is the same computation as groupby(["channel", "year"])["revenue"].sum().unstack(). Use groupby when you will keep computing, and pivot_table when the grid itself is the output. For counts of distinct orders, change the aggregation: values="order_id", aggfunc="nunique". And fill_value=0 replaces the NaN of empty cells when zero is the honest answer, as it is for “no sales in that cell”.

From amounts to shares

A grid of amounts invites the next question: how did the mix change? Divide each column by its total and the grid shows shares instead. by_year.sum() returns one total per column, and dividing a DataFrame by that Series lines each total up with its own column by label, the same alignment rule you met with two Series in Lesson 2.1. To divide each row by its row total instead, use by_year.div(by_year.sum(axis=1), axis=0).

Python
import pandas as pd

items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
items["revenue"] = items["quantity"] * items["unit_price"] * (1 - items["discount"])
sales = items.merge(orders, on="order_id", how="left", validate="many_to_one")
sales = sales[sales["status"] != "cancelled"]

by_year = sales.pivot_table(index="channel", columns=sales["order_date"].dt.year,
                            values="revenue", aggfunc="sum")
print((by_year / by_year.sum()).round(3))

The web’s share of revenue rose from 53% in 2024 to 59% in 2025, while the app’s fell from 35% to 31%. Amounts tell you everything grew; shares tell you where the growth concentrated. Report both, because each hides what the other shows.

crosstab: counts and rates

For two categorical columns, pd.crosstab counts combinations, and normalize converts counts to shares. Here is Cartwheel’s web traffic:

Python
import pandas as pd

sessions = pd.read_csv("web_sessions.csv")
print(pd.crosstab(sessions["source"], sessions["device"]))
print(pd.crosstab(sessions["device"], sessions["purchased"], normalize="index").round(3))

The second table answers a real question. normalize="index" makes each row sum to 1, so the 1 column is the purchase rate per device: 12.4% of desktop sessions end in a purchase, but only 6.7% of mobile sessions, although mobile brings the most traffic. That gap is worth a conversation with whoever owns the mobile checkout. Choose the normalisation by the question: "index" for rates within each row group, "columns" for composition of each column, "all" for shares of the grand total.

melt: from wide back to long

The finance team’s 2025 targets arrive as a spreadsheet, one column per quarter. To compare them with actual revenue you need them long, one row per channel and quarter, so they can be merged:

Python
import pandas as pd

targets = pd.DataFrame({
    "channel": ["marketplace", "mobile_app", "web"],
    "Q1": [5000, 15000, 28000],
    "Q2": [5000, 16000, 30000],
    "Q3": [6000, 20000, 40000],
    "Q4": [9000, 30000, 60000],
})
long = targets.melt(id_vars="channel", var_name="quarter", value_name="target")
long["quarter"] = long["quarter"].str[1].astype(int)
print(long.head(6))
print(long.shape)

id_vars are the columns that identify a row and stay as they are; every other column becomes a pair of values in two new columns, the old column name in var_name and its value in value_name. Three rows times four quarters gives twelve. str[1] takes the second character of "Q1", so the quarter becomes a number that matches sales["quarter"], ready for a merge like the ones in Lesson 4.2.

In the exercise you build the quarterly grid for 2025 and check performance against those targets. Next comes time itself: resampling, periods and growth rates.

Key takeaways

  • Long data has one row per observation; wide data spreads one variable across columns. pandas analyses long data best, and people read wide tables best.
  • pivot_table(index=..., columns=..., values=..., aggfunc="sum") aggregates and reshapes in one step, like a spreadsheet pivot table.
  • pivot only reshapes; it raises ValueError: Index contains duplicate entries when several rows share a cell, so use pivot_table for raw data.
  • pd.crosstab(a, b, normalize="index") turns two columns into row percentages, which is the quickest way to compare rates between groups.
  • melt(id_vars=..., var_name=..., value_name=...) turns wide columns such as Q1…Q4 back into rows.

Further reading

Check your understanding

3 questions

Question 1 of 3

sales.pivot(index="quarter", columns="channel", values="revenue") raises ValueError: Index contains duplicate entries, cannot reshape. What is the fix?

Question 2 of 3

A finance sheet has columns channel, Q1, Q2, Q3, Q4. Which call makes one row per channel and quarter?

Question 3 of 3

pd.crosstab(sessions["device"], sessions["purchased"], normalize="index") shows 0.067 in the mobile row, column 1. What does it mean?

Practice

Python exercise

2025 against target

sales_2025 holds 2025’s non-cancelled order lines, with quarter and channel columns, and targets is the finance team’s wide sheet. Build grid, a pivot table of revenue with quarters 1 to 4 as rows and channels as columns. Melt targets into targets_long with the columns channel, quarter (an integer 1 to 4) and target. Then make vs_target: targets_long plus a revenue column with the actual revenue for each channel and quarter. Finally set beat to the number of rows where revenue reached the target.

Data files: order_items.csv, orders.csv

import pandas as pd

items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
items["revenue"] = items["quantity"] * items["unit_price"] * (1 - items["discount"])
sales = items.merge(orders, on="order_id", how="left", validate="many_to_one")
sales = sales[sales["status"] != "cancelled"]
sales = sales.assign(year=sales["order_date"].dt.year, quarter=sales["order_date"].dt.quarter)
sales_2025 = sales[sales["year"] == 2025]

targets = pd.DataFrame({
    "channel": ["marketplace", "mobile_app", "web"],
    "Q1": [5000, 15000, 28000],
    "Q2": [5000, 16000, 30000],
    "Q3": [6000, 20000, 40000],
    "Q4": [9000, 30000, 60000],
})

# 1. grid: 2025 revenue with quarters (1-4) as rows and channels as columns
grid = None

# 2. targets_long: columns channel, quarter (int 1-4), target - 12 rows
targets_long = None

# 3. vs_target: targets_long with an added "revenue" column (actual 2025 revenue
#    for that channel and quarter)
vs_target = None

# 4. Number of channel-quarters where revenue reached the target
beat = None
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

(no output)

Checks

  • grid is 4 quarters × 3 channels, with web Q4 at about 87,530
  • targets_long has 12 rows with integer quarters
  • vs_target has 12 rows and actual revenue adding up to 2025’s total
  • beat is 8