Unpivot a Wide Excel Sheet with pandas melt
Spreadsheets grow sideways. A report starts with one column per month, and two years later it has twenty-four value columns, a new one added every reporting cycle, and every formula and chart has to be rewritten each time. Analysis wants the opposite shape: one row per observation, with the period as a value rather than a column name. pd.melt performs that reshape in one call — and the details that matter are which columns you name as identifiers, and turning the header text back into real dates. This guide covers both. It extends Creating Pivot Tables from Excel Data.
Prerequisites
pip install pandas openpyxl xlsxwriter
A wide sheet of the kind that accumulates:
import pandas as pd
wide = pd.DataFrame({
"region": ["North", "South", "West"],
"owner": ["A. Chen", "B. Ortiz", "C. Novak"],
"2026-06": [5150.00, 4268.50, 3511.25],
"2026-07": [4820.50, 3980.25, 2711.50],
"2026-08": [5402.75, 3140.75, 1820.00],
})
wide.to_excel("wide_report.xlsx", index=False)
Step 1 — Melt, naming the identifiers
melt splits the columns into two groups: the identifiers that stay as columns, and everything else, which collapses into a name column and a value column.
import pandas as pd
wide = pd.read_excel("wide_report.xlsx")
long = wide.melt(
id_vars=["region", "owner"], # stay as columns
var_name="month", # the old column names land here
value_name="revenue", # the old cell values land here
)
print(long.head())
# region owner month revenue
# 0 North A. Chen 2026-06 5150.00
# 1 South B. Ortiz 2026-06 4268.50
Name the identifiers, not the values. melt also accepts value_vars, and using it looks equivalent — but it is not. Next month a 2026-09 column appears, and a value_vars list silently omits it while an id_vars list picks it up automatically:
# Fragile: needs editing every month.
long = wide.melt(id_vars="region",
value_vars=["2026-06", "2026-07", "2026-08"])
# Robust: any new period column is included without a change.
long = wide.melt(id_vars=["region", "owner"],
var_name="month", value_name="revenue")
That single choice is the difference between a script that keeps working and one that quietly under-reports from the month somebody adds a column.
Step 2 — Turn the header text into real dates
The month column is text, so it sorts alphabetically and cannot be grouped by quarter. Parse it once, after melting — which is one conversion over a column rather than one per header:
import pandas as pd
long["month"] = pd.to_datetime(long["month"], format="%Y-%m", errors="coerce")
unparsed = long["month"].isna().sum()
if unparsed:
print(f"warning: {unparsed} row(s) had an unparseable period label")
Headers are rarely as tidy as 2026-06. Real ones look like Jun-26, Q3 2026 or Aug Actual, so extract the part that is a date before parsing:
import pandas as pd
def parse_period(labels, fmt="%b-%y"):
"""Pull a period out of a messy column heading and parse it."""
text = labels.astype("string").str.strip()
extracted = text.str.extract(
r"([A-Za-z]{3}[- ]?\d{2,4}|\d{4}[-/]\d{2})", expand=False
)
cleaned = extracted.str.replace(" ", "-", regex=False)
return pd.to_datetime(cleaned, format=fmt, errors="coerce")
Once the column is real dates, everything in grouping Excel rows by month and quarter becomes available — quarterly rollups, fiscal periods, gap filling.
Step 3 — Handle a two-row header
Wide sheets often stack a period row above a measure row: Q1 spanning Units, Revenue, Margin, then Q2 doing the same. Read both header rows and melt on the levels.
import pandas as pd
stacked = pd.read_excel("quarterly_wide.xlsx", header=[0, 1], index_col=0)
stacked.columns.names = ["period", "measure"]
long = (
stacked.stack(["period", "measure"], future_stack=True)
.rename("value")
.reset_index()
)
print(long.head())
Using stack here rather than melt is the exception to the earlier rule — stack understands index levels natively, and a MultiIndex column is exactly that. future_stack=True opts into the newer behaviour, which keeps rows whose value is missing rather than silently dropping them. Reading multi-level headers is covered in skipping rows and setting the header.
Step 4 — Drop or keep the empty cells
A wide grid is usually sparse: not every region has a value in every month. Melting turns each empty cell into a row with a NaN value, which can multiply the row count considerably.
import pandas as pd
long = wide.melt(id_vars=["region", "owner"],
var_name="month", value_name="revenue")
print(f"{len(long)} rows, {long['revenue'].isna().sum()} of them empty")
# Keep only real observations — usually right for analysis.
observed = long.dropna(subset=["revenue"])
# Or keep them, when an absent month genuinely means zero.
zeroed = long.fillna({"revenue": 0})
Choose deliberately. Dropping is right when a blank means "no data recorded"; filling with zero is right when it means "nothing happened". Getting it backwards either understates a total or invents activity — the distinction developed in finding and reporting missing values.
Step 5 — Write the long form back
The long shape is what every downstream tool wants — pivot tables, charts, database loads:
import pandas as pd
def write_long(long, path, sheet_name="Data", table_name="Observations"):
"""Write the unpivoted frame as a named table, ready to pivot from."""
with pd.ExcelWriter(path, engine="xlsxwriter",
date_format="yyyy-mm-dd") as writer:
long.to_excel(writer, sheet_name=sheet_name, index=False)
sheet = writer.sheets[sheet_name]
sheet.add_table(0, 0, len(long), len(long.columns) - 1,
{"name": table_name,
"columns": [{"header": str(c)} for c in long.columns],
"style": "Table Style Medium 2"})
money = writer.book.add_format({"num_format": "#,##0.00"})
sheet.set_column("A:B", 16)
sheet.set_column("C:C", 13)
sheet.set_column("D:D", 14, money)
sheet.freeze_panes(1, 0)
return path
write_long(observed, "long_report.xlsx")
Writing it as a named table is deliberate: a reader can then insert a pivot over it and reproduce the original wide view interactively, which is strictly better than the fixed wide sheet you started with. That round trip is described in adding a native Excel pivot table with Python.
Going back to wide, when a printed report needs it, is a pivot:
back_to_wide = observed.pivot(index=["region", "owner"],
columns="month", values="revenue").reset_index()
Common pitfalls and fixes
| Symptom | Cause | Fix |
|---|---|---|
| New month column ignored | value_vars hard-coded | Name id_vars instead. |
| Identifier columns became values | Not listed in id_vars | Add every identifier to the list. |
| Month column sorts alphabetically | Still text | Parse it with to_datetime after melting. |
| Row count exploded | Empty grid cells became rows | dropna(subset=[value]), or fill deliberately. |
KeyError on id_vars | Column name has whitespace | Normalise the headers first. |
| Two header rows produce tuples | MultiIndex columns | stack the levels, or flatten first. |
Values became object dtype | Mixed types across the wide columns | Coerce after melting, in one column. |
| Rows silently disappeared | stack dropped missing values | Pass future_stack=True. |
Performance and scale notes
melt allocates one long frame roughly the size of the wide one, and copies the identifier columns once per value column. A frame with two identifiers and twenty-four months therefore repeats each identifier twenty-four times — which is the memory cost of the long shape, not of the operation.
Three habits keep that manageable. Melt before cleaning the values, since cleaning one long column is far cheaper than cleaning twenty-four wide ones:
import pandas as pd
long = wide.melt(id_vars=["region", "owner"],
var_name="month", value_name="revenue")
long["revenue"] = pd.to_numeric(long["revenue"], errors="coerce") # one pass
Make the repeated identifiers categorical after melting. A region name repeated twenty-four times is stored once with an integer code, which on a large frame is a substantial saving:
for name in ("region", "owner"):
long[name] = long[name].astype("category")
Drop the empty cells early. A sparse grid can more than double the row count with rows carrying no information, and every subsequent operation pays for them.
For a genuinely large wide sheet, melt column-group by column-group and concatenate, so peak memory holds one slice rather than the whole long frame at once:
import pandas as pd
identifiers = ["region", "owner"]
periods = [c for c in wide.columns if c not in identifiers]
pieces = []
for batch_start in range(0, len(periods), 6):
batch = periods[batch_start:batch_start + 6]
piece = wide[identifiers + batch].melt(
id_vars=identifiers, var_name="month", value_name="revenue"
).dropna(subset=["revenue"])
pieces.append(piece)
long = pd.concat(pieces, ignore_index=True)
Dropping inside each batch is what makes this worthwhile — the empty cells never accumulate. For files too large to read at all, the chunked approach in reading large Excel files in chunks composes with this cleanly, because melting is row-independent.
Conclusion
pd.melt turns a sideways-growing spreadsheet into the shape every analysis tool wants, and the single most important choice is to name the identifier columns rather than the value columns — that is what makes the script survive a new month being added. Parse the resulting period column into real dates so it sorts and groups properly, decide deliberately whether an empty grid cell means "no data" or "zero", and write the long form back as a named table so readers can pivot it into whatever view they need. The wide sheet was one view; the long form is the data.
Frequently asked questions
What is the difference between melt and stack?melt works on columns and returns a flat DataFrame with the former column names in a variable column. stack works on the index and returns a Series with a MultiIndex. melt is almost always the clearer choice when unpivoting a spreadsheet.
How do I keep more than one identifier column?
Pass them all as a list to id_vars. Everything not listed there is treated as a value column, so listing the identifiers is safer than listing the values when new period columns get added each month.
The column names are months — how do I turn them into dates?
Melt first, then parse the resulting variable column with pd.to_datetime and a format string matching the header text. Parsing after melting means one conversion over a column instead of one per header.
What if the sheet has two header rows?
Read it with header set to a list so the columns become a MultiIndex, then stack the levels. pandas will produce one column per header level, which is usually exactly what you want.
Should I unpivot before or after cleaning? Unpivot first when the cleaning applies to values, because one long value column is far easier to clean than twelve wide ones. Clean the identifier columns before, since they are unaffected by the reshape.
Related
- Up to the parent: Creating Pivot Tables from Excel Data — the opposite reshape.
- Create a Pivot Table from Excel with pandas — going from long back to wide.
- Add a Native Excel Pivot Table with Python — letting readers pivot the long form themselves.
- Group Excel Rows by Month and Quarter with pandas — what the parsed period column unlocks.
- Skip Rows and Set the Header When Reading Excel with pandas — reading the two-row headers this handles.