Guide
Export a Pandas Pivot Table to Excel (Formatted)
Build a pandas pivot_table, flatten its MultiIndex, export with to_excel, then add a styled header, number formats, and a grand-total row with openpyxl.
To export a pandas pivot table to a clean, formatted Excel file, build the table with pivot_table(), flatten any MultiIndex on the columns and index so it writes as a flat grid, save it with to_excel(), then reopen the file with openpyxl to apply a styled header and number formats. The flattening step is what keeps the output from exporting as messy stacked headers. This page is part of the Creating Pivot Tables from Excel Data cluster.
Prerequisites
pip install pandas openpyxl
Every block below runs in order against the sample data built first.
Build the pivot table with a grand total
Set margins=True to append a grand-total row and column. margins_name controls the label so it reads cleanly:
import pandas as pd
df = pd.DataFrame({
"Region": ["East", "East", "West", "West", "East", "West"],
"Product": ["Widget", "Gadget", "Widget", "Gadget", "Widget", "Widget"],
"Quarter": ["Q1", "Q1", "Q1", "Q2", "Q2", "Q2"],
"Sales": [1200.5, 800.0, 950.25, 1100.0, 1350.75, 700.0],
})
pivot = pd.pivot_table(
df,
index=["Region", "Product"],
columns="Quarter",
values="Sales",
aggfunc="sum",
fill_value=0,
margins=True,
margins_name="Total",
)
print(pivot)
This produces a MultiIndex on the rows (Region, Product) and named columns (Q1, Q2, Total). Written as-is, the row index spans two header levels and the column index name leaks into the corner cell.
Flatten the index and columns
reset_index() turns the row MultiIndex into regular columns. If the columns are also a MultiIndex (which happens when you pass multiple values or stacked columns), flatten them with to_flat_index() and join the tuples into readable strings:
flat = pivot.reset_index()
# Flatten MultiIndex columns if present
if isinstance(flat.columns, pd.MultiIndex):
flat.columns = ["_".join(str(c) for c in col if c != "").strip("_")
for col in flat.columns.to_flat_index()]
# Drop the leftover "Quarter" columns-axis name
flat.columns.name = None
print(flat)
For this example the columns are single-level, so reset_index() plus clearing columns.name is enough. Keep the MultiIndex branch in your pipeline so it stays correct when the shape changes.
Write the flattened table to Excel
flat.to_excel("pivot_report.xlsx", index=False, sheet_name="Summary",
engine="openpyxl")
print("Wrote pivot_report.xlsx")
Pass index=False because the index is already a real column after reset_index() — otherwise you get a duplicate, unnamed first column.
Style the header and apply number formats
pandas writes values but not formatting. Reopen the saved file with openpyxl to bold the header, fill it, and apply a currency number format to the numeric columns:
from openpyxl import load_workbook
from openpyxl.styles import Font, PatternFill, Alignment
wb = load_workbook("pivot_report.xlsx")
ws = wb["Summary"]
header_fill = PatternFill("solid", fgColor="1F4E78")
header_font = Font(bold=True, color="FFFFFF")
for cell in ws[1]:
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal="center")
# Currency format on every numeric column (skip the two text key columns)
for row in ws.iter_rows(min_row=2, min_col=3):
for cell in row:
cell.number_format = '#,##0.00'
# Bold the grand-total row (last row)
for cell in ws[ws.max_row]:
cell.font = Font(bold=True)
ws.column_dimensions["A"].width = 12
ws.column_dimensions["B"].width = 12
wb.save("pivot_report.xlsx")
print(f"Formatted {ws.max_row - 1} data rows")
number_format is a cell-level string; '#,##0.00' gives thousands separators and two decimals. Adjusting min_col skips the text key columns so only the numbers get the currency format.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Stacked, half-empty header rows in Excel | MultiIndex columns written directly | Flatten with to_flat_index() and join the tuples |
| Empty extra column on the left | Row MultiIndex written as the index | reset_index(), then to_excel(index=False) |
Stray Quarter label in a corner cell | Columns-axis name survives the write | flat.columns.name = None |
Grand total labeled All | Default margins_name | Pass margins_name="Total" |
| Formatting gone after editing in pandas | Re-reading with read_excel strips styles | Apply styles last, with openpyxl, and don't round-trip |
| Index name lost after flatten | reset_index consumes index names into columns | Read them from the new column headers, not df.index.name |
The last row matters most: any time you read a formatted file back into pandas and re-write it, all openpyxl styling is discarded. Treat the openpyxl pass as the final step and never funnel a styled workbook back through pd.read_excel/to_excel.
Performance and scale note
The pivot and flatten are vectorized and trivial even for large source frames; the openpyxl styling loop is the bottleneck because it touches each cell in Python. For pivots up to a few thousand rows this is instant. If you are formatting tens of thousands of rows, prefer the xlsxwriter engine with pd.ExcelWriter(path, engine="xlsxwriter") and apply a single set_column(..., cell_format) per column instead of looping cells — column-level formats are far faster than per-cell assignment.
Frequently asked questions
Why do my pivot columns export as messy stacked headers? Because pivot_table produced a MultiIndex on the columns and to_excel writes each level as its own header row. Flatten with df.columns = df.columns.to_flat_index() and join the tuples into single strings before writing.
How do I add a grand-total row? Pass margins=True to pivot_table. Use margins_name="Total" to rename the default All label on both the total row and column.
Why is there a blank extra column on the left of my sheet? The row MultiIndex was written as the spreadsheet index. Call reset_index() to turn it into real columns, then to_excel(..., index=False).
Can I format the pivot without reopening the file? Yes — write through pd.ExcelWriter with engine="xlsxwriter" and apply formats in the same context manager. The openpyxl reopen approach is simpler when the file already exists.
Why did my formatting disappear? You likely read the styled file back with pd.read_excel and re-wrote it. pandas does not preserve cell styles, so make the openpyxl/xlsxwriter formatting your final step.
Conclusion
A clean formatted pivot export is four steps: pivot with margins=True, flatten the MultiIndex with reset_index and to_flat_index, write with index=False, then style once with openpyxl. Keeping the flatten and style steps separate from any re-read is what guarantees a tidy, formatted result.
Where to go next
- Creating Pivot Tables from Excel Data — the parent cluster.
- Create a Pivot Table From Excel With Pandas — the pivot fundamentals behind this export.
- Applying Number and Date Formats in Excel — deeper number-format strings for the styling step.
- Building Multi-Sheet Excel Dashboards — combine several formatted pivots into one workbook.