pivot_table / melt
Pivot turns long-format data (one row per observation) into wide-format (one row per entity, columns per category). pivot errors on duplicates; pivot_table aggregates them; crosstab handles frequency counts. Reshape is the foundation of every analytical report.
pivot, pivot_table, crosstab, melt
EXAMPLE
import pandas as pd
import numpy as np
# 1) Sample data — sales by region by quarter
sales = pd.DataFrame({
'region': ['NSW', 'NSW', 'VIC', 'VIC', 'QLD', 'QLD', 'NSW', 'VIC'],
'quarter': ['Q1', 'Q2', 'Q1', 'Q2', 'Q1', 'Q2', 'Q1', 'Q1'],
'amount': [100, 150, 80, 90, 60, 70, 25, 10],
})
# 2) pivot — strict; errors on duplicates
sales_unique = sales.drop_duplicates(subset=['region', 'quarter'])
sales_unique.pivot(index='region', columns='quarter', values='amount')
# quarter Q1 Q2
# region
# NSW 100 150
# VIC 80 90
# QLD 60 70
# pivot fails on duplicates:
# sales.pivot(index='region', columns='quarter', values='amount')
# → ValueError: Index contains duplicate entries
# 3) pivot_table — aggregates duplicates
sales.pivot_table(index='region', columns='quarter', values='amount', aggfunc='sum')
# Same shape, but NSW Q1 = 125 (100 + 25), VIC Q1 = 90 (80 + 10)
sales.pivot_table(index='region', columns='quarter', values='amount', aggfunc='mean')
sales.pivot_table(index='region', columns='quarter', values='amount', aggfunc=['sum', 'mean'])
# Multiple aggregations + multiple value columns
sales.pivot_table(
index='region',
columns='quarter',
values='amount',
aggfunc={'amount': ['sum', 'mean', 'count']},
margins=True, # add totals row + column
margins_name='Total',
fill_value=0, # replace NaN with 0
)
# 4) Hierarchical columns + rows
sales = pd.DataFrame({
'region': ['NSW']*4 + ['VIC']*4,
'channel': ['retail','retail','online','online']*2,
'quarter': ['Q1','Q2','Q1','Q2']*2,
'amount': [100, 150, 50, 75, 80, 90, 40, 55],
})
sales.pivot_table(
index=['region', 'channel'],
columns='quarter',
values='amount',
aggfunc='sum',
)
# quarter Q1 Q2
# region channel
# NSW online 50 75
# retail 100 150
# VIC online 40 55
# retail 80 90
# 5) crosstab — frequency / cross-tabulation
survey = pd.DataFrame({
'gender': ['M','M','F','F','M','F','F','M','F','M'],
'response': ['yes','no','yes','yes','no','yes','no','yes','yes','no'],
})
pd.crosstab(survey['gender'], survey['response'])
# response no yes
# gender
# F 1 4
# M 2 3
# Normalize to percentages
pd.crosstab(survey['gender'], survey['response'], normalize='index')
pd.crosstab(survey['gender'], survey['response'], normalize='columns')
pd.crosstab(survey['gender'], survey['response'], normalize=True) # over total
# With margins (row/column totals)
pd.crosstab(survey['gender'], survey['response'], margins=True, margins_name='Total')
# 6) melt — wide → long (inverse of pivot)
wide = pd.DataFrame({
'name': ['Mara', 'Sam'],
'jan': [100, 200],
'feb': [150, 180],
'mar': [200, 220],
})
wide.melt(id_vars='name', var_name='month', value_name='sales')
# name month sales
# 0 Mara jan 100
# 1 Sam jan 200
# 2 Mara feb 150
# 3 Sam feb 180
# ...
# Multiple ID columns
wide.melt(
id_vars=['name', 'region'],
value_vars=['jan', 'feb', 'mar'],
var_name='month',
value_name='sales',
)
# 7) stack + unstack — multi-index reshape
df = sales.pivot_table(index='region', columns='quarter', values='amount', aggfunc='sum')
df.stack() # columns → inner row index
df.stack().unstack() # inverse
df.stack().unstack(level='quarter') # explicit level
# 8) Computed columns inside pivot
sales['high_value'] = sales['amount'] > 75
sales.pivot_table(
index='region',
columns='high_value',
values='amount',
aggfunc='count',
fill_value=0,
)
# 9) Custom aggregation functions
def range_size(x): return x.max() - x.min()
sales.pivot_table(
index='region', columns='quarter', values='amount',
aggfunc=range_size,
)
# Lambdas
sales.pivot_table(
index='region', columns='quarter', values='amount',
aggfunc=lambda x: x.quantile(0.9),
)
# 10) Pivot for time-series (date → columns)
ts = pd.DataFrame({
'date': pd.to_datetime(['2024-01-01','2024-01-02','2024-01-01','2024-01-02']),
'metric': ['users','users','revenue','revenue'],
'value': [100, 110, 5000, 5500],
})
ts.pivot_table(index='date', columns='metric', values='value')
# metric revenue users
# date
# 2024-01-01 5000.0 100.0
# 2024-01-02 5500.0 110.0
# 11) Pivot then plot
import matplotlib.pyplot as plt
wide = sales.pivot_table(index='region', columns='quarter', values='amount', aggfunc='sum')
wide.plot.bar(stacked=True)
plt.tight_layout()
# 12) Performance tips
# • For huge data, prefer groupby + unstack over pivot_table (it's a thin wrapper anyway)
# • Use observed=True with categorical columns to avoid materialising unused categories
# • Sort beforehand for repeatable column ordering
# • Polars / DuckDB outperform pandas for very large pivots
# 13) Common bugs
# • pivot fails on duplicates → use pivot_table with aggfunc
# • Wrong aggfunc → silent miscount; 'sum' counts None as 0, 'count' ignores NaN
# • Missing fill_value → NaN columns where no data
# • Long-format columns containing nulls → silently dropped from index
# • Stack/unstack on MultiIndex with NaN level → 'Series have different lengths'
# • melt with var_name reserved word → name collision; pick distinct names
# • crosstab vs pivot_table for frequency — both work; crosstab is shorter for plain counts
# • Multi-aggfunc returns MultiIndex columns; flatten with df.columns.map('_'.join)
Why it matters
Use pivot_table for safe wide-format aggregation, crosstab for frequency tables, and melt to go back to long format for plotting libraries. Pivot is strict (errors on duplicates) — that’s often what you want when you expect unique combinations, so use it as a guard.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
import pandas as pd
pivot = df.pivot_table(
index='month', columns='product',
values='amount', aggfunc='sum', fill_value=0,
)
long = pivot.reset_index().melt(id_vars='month')
Try it Yourself »
Discussion
Loading…