Wide data is easy to read but hard to analyze. Long data is easy to analyze but hard to present. This lesson teaches you exactly when to use melt, pivot, and unstack in pandas — with realistic examples and a clear workflow for converting between formats.

Picture this: your finance team emails you a spreadsheet with monthly sales figures, and each month is its own column — January, February, March, all the way to December. It looks tidy at a glance. But the moment you try to filter it, chart it, or run any groupby aggregation on it, you hit a wall. pandas wants data in rows, not spread across columns. That spreadsheet is wide data, and nearly every data professional has to wrestle with it eventually.
The flip side happens just as often. You pull a clean, analysis-ready dataset from a database — one row per transaction, properly normalized — and your manager asks for a summary table with regions across the top, product lines down the side, and revenue in the cells. Now your data is too tall. You need to pivot it wide.
Understanding how to flip between wide and long formats isn't just a pandas trick. It's a fundamental skill in data work, as important as filtering or joining. By the end of this lesson, you'll be able to look at any dataset and immediately recognize which format it's in, decide which format you need, and use the right pandas tool to get there.
What you'll learn:
melt() to convert wide data into long formatpivot() to convert long data into wide formatunstack() when you're working with grouped or indexed dataYou should be comfortable loading a CSV into a pandas DataFrame and doing basic column selection. If you're newer to pandas, work through Your First pandas DataFrame: Loading CSV and Excel Files and Exploring Data before this lesson. You should also know what a groupby operation looks like — if that's unfamiliar, Grouping and Aggregating in pandas: groupby as the PivotTable Replacement will fill that gap quickly.
Before touching any code, let's build clear mental models of these two formats.
Wide data stores multiple observations per row by spreading them across columns. Think of an Excel report where each row is a product and each column is a month. Every cell holds a single value, but related values (sales figures) are scattered horizontally.
Long data stores one observation per row. Instead of twelve month columns, you have one column called month and one column called sales. The product appears in multiple rows — once for each month.
Here's a concrete comparison. Wide format:
| product | jan | feb | mar |
|---|---|---|---|
| Keyboard | 1200 | 980 | 1100 |
| Monitor | 3400 | 3100 | 3600 |
Long format of the exact same data:
| product | month | sales |
|---|---|---|
| Keyboard | jan | 1200 |
| Keyboard | feb | 980 |
| Keyboard | mar | 1100 |
| Monitor | jan | 3400 |
| Monitor | feb | 3100 |
| Monitor | mar | 3600 |
Neither format is wrong. They're suited to different jobs:
groupby, filtering, charting, and machine learning all prefer it.Key insight
Most of your analysis work happens in long format. Most of your reporting work happens in wide format. Reshaping is the bridge between the two.
melt() is the function you reach for when data is spread too wide. It takes columns and "melts" them down into rows.
Let's build a realistic example. Suppose you have quarterly revenue data for three sales regions:
import pandas as pd
revenue = pd.DataFrame({
'region': ['North', 'South', 'West'],
'Q1_2023': [142000, 98000, 175000],
'Q2_2023': [160000, 112000, 188000],
'Q3_2023': [155000, 105000, 201000],
'Q4_2023': [178000, 130000, 215000],
})
print(revenue)
Output:
region Q1_2023 Q2_2023 Q3_2023 Q4_2023
0 North 142000 160000 155000 178000
1 South 98000 112000 105000 130000
2 West 175000 188000 201000 215000
Now let's melt it:
revenue_long = revenue.melt(
id_vars='region', # columns to keep as-is
var_name='quarter', # name for the new "variable" column
value_name='revenue' # name for the new "value" column
)
print(revenue_long)
Output:
region quarter revenue
0 North Q1_2023 142000
1 South Q1_2023 98000
2 West Q1_2023 175000
3 North Q2_2023 160000
4 South Q2_2023 112000
5 West Q2_2023 188000
6 North Q3_2023 155000
7 South Q3_2023 105000
8 West Q3_2023 201000
9 North Q4_2023 178000
10 South Q4_2023 130000
11 West Q4_2023 215000
Each row now represents one region-quarter combination. You can now do things that were impossible before:
# Average revenue by region across all quarters
print(revenue_long.groupby('region')['revenue'].mean())
# Filter to just Q3
q3_only = revenue_long[revenue_long['quarter'] == 'Q3_2023']
Tip
You can pass a list to id_vars if you have multiple identifier columns to preserve. For example, id_vars=['region', 'manager'] keeps both columns intact while melting the rest.
Sometimes you don't want to melt every non-identifier column. Use the value_vars parameter to specify exactly which columns to melt:
# Only melt Q1 and Q2
partial_melt = revenue.melt(
id_vars='region',
value_vars=['Q1_2023', 'Q2_2023'],
var_name='quarter',
value_name='revenue'
)
This gives you six rows instead of twelve — only the quarters you specified.
pivot() does the opposite of melt(). It takes a long dataset and spreads one column's unique values out into new columns. This is what you'd use to build a summary table or a crosstab-style report.
Using the long DataFrame we just created:
revenue_wide = revenue_long.pivot(
index='region', # what becomes the row labels
columns='quarter', # unique values that become column headers
values='revenue' # what fills the cells
)
print(revenue_wide)
Output:
quarter Q1_2023 Q2_2023 Q3_2023 Q4_2023
region
North 142000 160000 155000 178000
South 98000 112000 105000 130000
West 175000 188000 201000 215000
You're back to the wide format — but now with region as the index. You can reset the index if you want it as a plain column:
revenue_wide = revenue_wide.reset_index()
Warning
pivot() requires that each combination of index and columns values is unique. If your data has duplicate entries — for example, two rows with region='North' and quarter='Q1_2023' — pandas will raise a ValueError. This is the single most common error people hit with pivot(). If your data might have duplicates, use pivot_table() instead, which can aggregate them.
Let's say your data contains duplicate entries because two salespeople logged revenue for the same region and quarter:
dupes = pd.DataFrame({
'region': ['North', 'North', 'South'],
'quarter': ['Q1_2023', 'Q1_2023', 'Q1_2023'],
'revenue': [80000, 62000, 98000]
})
# This will raise a ValueError
# dupes.pivot(index='region', columns='quarter', values='revenue')
# This works - it sums the duplicates
dupes.pivot_table(
index='region',
columns='quarter',
values='revenue',
aggfunc='sum'
)
pivot_table() accepts an aggfunc argument — 'sum', 'mean', 'count', and so on — so it gracefully handles duplicates by aggregating them. Think of it as pivot() with a safety net. For a deeper look at pivot_table(), see Reshaping Data with pivot_table, melt, and stack in pandas.
unstack() is the function that trips people up the most, because it only makes sense once you understand pandas MultiIndex. But once it clicks, it becomes incredibly useful for building clean reporting tables directly from grouped data.
When you group by more than one column, pandas creates a MultiIndex — a hierarchical index with multiple levels. Let's see this in action:
# Simulate transaction-level sales data
import numpy as np
np.random.seed(42)
sales = pd.DataFrame({
'region': np.repeat(['North', 'South', 'West'], 8),
'category': np.tile(['Electronics', 'Furniture', 'Apparel', 'Tools'], 6),
'revenue': np.random.randint(5000, 50000, 24)
})
grouped = sales.groupby(['region', 'category'])['revenue'].sum()
print(grouped)
Output (abbreviated):
region category
North Apparel 43210
Electronics 78900
Furniture 32100
Tools 54300
South Apparel 29800
Electronics 61200
Furniture 41500
Tools 38700
West Apparel 55600
Electronics 82300
Furniture 47800
Tools 63400
dtype: int64
This is a Series with a two-level index. The outer level is region, the inner level is category. It's technically long format, but hard to scan.
unstack() takes one level of the MultiIndex and pivots it out into columns:
report_table = grouped.unstack()
print(report_table)
Output:
category Apparel Electronics Furniture Tools
region
North 43210 78900 32100 54300
South 29800 61200 41500 38700
West 55600 82300 47800 63400
By default, unstack() moves the innermost index level (category) out to become column headers. The result is a clean, human-readable summary table — exactly what you'd build manually in Excel.
Note
unstack() works on the innermost index level by default, but you can target any level by name or position: grouped.unstack(level='region') or grouped.unstack(level=0). Experimenting with different levels can produce surprisingly useful variations of the same table.
If some combinations don't exist in your data, unstack() will introduce NaN values. You can fill them with zero (or any other sensible default) right away:
report_table = grouped.unstack(fill_value=0)
This is much cleaner than chasing down NaNs after the fact. If you're dealing with NaN-heavy datasets generally, Cleaning Messy Data with pandas: Missing Values, Duplicates, and Data Types covers that topic thoroughly.
For completeness, stack() is unstack()'s counterpart. It takes column headers and collapses them back into index levels — effectively making a wide DataFrame taller and narrower. It's particularly useful after you've received a wide pivot table and need to get it back into an analysis-friendly shape.
# Start with the wide report table from above
# Stack it back to long format
long_again = report_table.stack()
print(long_again.head(8))
region category
North Apparel 43210
Electronics 78900
Furniture 32100
Tools 54300
South Apparel 29800
Electronics 61200
Furniture 41500
Tools 38700
dtype: int64
You're back to the MultiIndex Series. Call .reset_index() to turn it into a flat DataFrame ready for further analysis.
When you're working with these complex multi-level structures extensively — say, after nested groupby operations or when preparing data for export — the lesson on Reshaping and Analyzing Multi-Level Data in pandas: Working with MultiIndex Columns and Rows After groupby and pivot_table goes much deeper.
Let's walk through a realistic end-to-end scenario. You receive a wide CSV of quarterly headcount by department, and you need to produce two outputs: a long format file for analysis and a summary pivot table for a stakeholder report.
import pandas as pd
# Step 1: Load the wide data
headcount = pd.DataFrame({
'department': ['Engineering', 'Marketing', 'Sales', 'HR'],
'Q1_2024': [45, 12, 28, 8],
'Q2_2024': [48, 14, 31, 8],
'Q3_2024': [52, 15, 35, 9],
'Q4_2024': [55, 16, 38, 9],
})
# Step 2: Melt to long format for analysis
headcount_long = headcount.melt(
id_vars='department',
var_name='quarter',
value_name='headcount'
)
# Step 3: Extract the year and quarter number (string cleanup)
headcount_long['year'] = headcount_long['quarter'].str[-4:]
headcount_long['q_num'] = headcount_long['quarter'].str[:2]
# Step 4: Aggregate — average headcount by year and department
summary = headcount_long.groupby(['year', 'department'])['headcount'].mean()
# Step 5: Unstack department into columns for a readable report
report = summary.unstack('department').round(1)
print(report)
Output:
department Engineering HR Marketing Sales
year
2024 50.0 8.5 14.25 33.0
Now you have a crisp summary table you can export directly to Excel. For exporting to formatted workbooks, Automating Excel Reports with pandas and openpyxl: Formatted Workbooks Without Manual Work walks you through adding formatting, headers, and styling.
Key insight
The pattern for reporting workflows is almost always the same: melt the raw data into long format → clean and enrich it → groupby and aggregate → unstack or pivot into wide format for presentation. Once that pattern is in your muscle memory, reshaping stops feeling like a chore.
Work through this exercise to consolidate what you've learned.
Setup: Create this DataFrame in a notebook or script:
import pandas as pd
survey = pd.DataFrame({
'employee_id': [1001, 1002, 1003, 1004],
'team': ['Alpha', 'Alpha', 'Beta', 'Beta'],
'satisfaction_2022': [7, 8, 6, 9],
'satisfaction_2023': [8, 7, 7, 8],
'satisfaction_2024': [9, 9, 8, 9],
})
Tasks:
Use melt() to convert this into long format. The employee_id and team columns should stay as identifier columns. Name the new variable column year and the value column score.
After melting, clean up the year column so it contains just the year number (e.g., 2022) rather than satisfaction_2022. You'll want to use .str.split('_').str[-1].
Use groupby() on team and year, then compute the mean score. Name this result team_scores.
Call unstack() on team_scores to get years as the index and teams as columns.
Bonus: Use pivot() to reconstruct a wide table from your melted DataFrame showing one row per employee_id and one column per year.
Try each step before looking at the solution logic. If you get stuck on the filtering syntax, Selecting and Filtering Data in pandas: loc, iloc, and Boolean Masks has everything you need.
If you call melt() without specifying id_vars, pandas will melt all columns, including the ones you meant to use as identifiers. Every column becomes a variable-value pair. Always double-check your id_vars.
ValueError: Index contains duplicate entries, cannot reshape
This means your data has more than one row with the same combination of index and columns values. Solution: switch to pivot_table() with an appropriate aggfunc.
After pivot(), you'll often see something like quarter printed above the column headers — that's the name of the axis. It can interfere with column access. Remove it with:
df.columns.name = None
unstack() operates on a MultiIndex. If your DataFrame has a regular single-level index, calling unstack() either fails or produces confusing results. You need to groupby() first (which creates the MultiIndex) or set_index() with multiple columns before calling unstack().
# Correct: set a two-level index first
headcount_long.set_index(['department', 'quarter'])['headcount'].unstack('quarter')
If some combinations of row-column values don't exist in your source data, the reshaped table will have NaN in those cells. Use fill_value=0 in pivot_table() or unstack(), or call .fillna(0) afterward. Just make sure zero is the right semantic choice — sometimes NaN is meaningful and shouldn't be replaced.
Warning
Replacing NaN with 0 can silently corrupt calculated metrics like averages. If you have 10 cells and 3 of them are genuinely missing, filling with 0 changes the average. Consider whether fillna(0) or dropping NaN rows is more appropriate for your context.
You now have a complete toolkit for reshaping data in pandas:
melt() — wide to long. Use it when columns represent observations (like months or quarters) and you need them as rows for analysis.pivot() — long to wide. Use it when you need to spread a categorical column out into column headers and you're confident there are no duplicate entries.pivot_table() — long to wide with aggregation. Use it when duplicates are possible or when you need to aggregate values in the process.unstack() — collapses a MultiIndex level into column headers. Use it after a multi-level groupby() to produce readable summary tables.stack() — the inverse of unstack(). Takes columns and folds them back into index levels.The real power here is chaining these operations. Melt your raw data, clean it, group it, aggregate it, then unstack it into a report — that four-step pattern will carry you through the vast majority of reporting workflows you'll encounter.
From here, good next directions include learning to visualize this reshaped data with matplotlib and seaborn (because long format plays beautifully with seaborn's plotting API), or diving into time series analysis to handle date-based reshaping scenarios like resampling and rolling windows. If your reshaped data ends up going into automated reports, Automating Excel Reports with pandas and openpyxl shows you how to package it into polished, formatted workbooks without touching Excel manually.
Python for Data Analysis