Add a Native Excel Pivot Table with Python
pandas.pivot_table produces a grid of numbers. A native Excel pivot table is something different: a live object the reader can pivot, filter, drill into and refresh. Producing one from Python is harder than it should be, because the pivot definition and its cache are among the few Excel structures openpyxl cannot build from scratch. This guide covers the three routes that actually work — a template with the pivot already in it, driving Excel through xlwings, and the portable static fallback — and how to choose between them. It extends Creating Pivot Tables from Excel Data.
Prerequisites
pip install pandas openpyxl xlsxwriter
pip install xlwings # only for the Windows route
Some source data to pivot:
import pandas as pd
sales = pd.DataFrame({
"date": pd.to_datetime(
["2026-06-03", "2026-06-19", "2026-07-02", "2026-07-22",
"2026-08-05", "2026-08-15"] * 2
),
"region": ["North", "South", "West", "North", "South", "West"] * 2,
"product": ["Widget"] * 6 + ["Gadget"] * 6,
"revenue": [5150.00, 4268.50, 3511.25, 2980.10, 3140.75, 1820.00,
4210.00, 3980.25, 2711.50, 3320.80, 2905.60, 1615.40],
})
Step 1 — Write the source as a named table
Whichever route you take, the source should be a named Excel table rather than a plain range. A table's reference expands automatically as rows are added, so a pivot pointed at it never needs its source updating:
import pandas as pd
def write_source_table(df, path, sheet_name="Data", table_name="SalesData"):
"""Write the source rows as a named Excel table, ready for a pivot."""
with pd.ExcelWriter(path, engine="xlsxwriter",
datetime_format="yyyy-mm-dd") as writer:
df.to_excel(writer, sheet_name=sheet_name, index=False, startrow=0)
sheet = writer.sheets[sheet_name]
sheet.add_table(
0, 0, len(df), len(df.columns) - 1,
{
"name": table_name,
"columns": [{"header": str(c)} for c in df.columns],
"style": "Table Style Medium 2",
},
)
sheet.set_column("A:A", 13)
sheet.set_column("B:C", 14)
sheet.set_column("D:D", 14,
writer.book.add_format({"num_format": "#,##0.00"}))
return path
write_source_table(sales, "sales.xlsx")
Two things this buys you beyond the pivot. Readers get filter dropdowns for free, and any formula elsewhere can reference SalesData[revenue] rather than a range that goes stale. The table mechanics are covered in creating an Excel table with Python.
Step 2 — The template route
This is the approach that works on a server and gives readers a live pivot. Build the pivot once, by hand, in a template workbook: a Data sheet with the named table, and a Pivot sheet with a pivot table pointed at it. Then the script only ever writes rows and flags the cache.
from openpyxl import load_workbook
import pandas as pd
def refresh_template(template, dest, df, sheet_name="Data"):
"""Fill a template's data sheet and mark its pivot caches to refresh."""
wb = load_workbook(template)
ws = wb[sheet_name]
# Clear previous rows, keeping the header.
if ws.max_row > 1:
ws.delete_rows(2, ws.max_row - 1)
for record in df.itertuples(index=False):
ws.append(list(record))
# Grow the table reference so the pivot sees every row.
for table in ws.tables.values():
first, _, last_col = table.ref.partition(":")
column_letters = "".join(c for c in last_col if c.isalpha())
table.ref = f"{first}:{column_letters}{len(df) + 1}"
# Ask Excel to rebuild the pivot cache when the file opens.
for pivot in wb._pivots:
pivot.cache.refreshOnLoad = True
wb.save(dest)
return dest
refreshOnLoad is the key. openpyxl cannot rebuild the cache itself — it has no calculation engine — but it can set the flag that makes Excel do it the moment a reader opens the workbook. From the reader's point of view the pivot is simply current.
Two constraints to respect. Keep the template's structure intact: renaming the data sheet or the table breaks the pivot's source reference, and openpyxl will not warn you. And write into the existing sheet rather than replacing it — the fill-only discipline described in populating an Excel template without losing formatting applies exactly.
Step 3 — The xlwings route
On Windows with Excel installed, you can build the pivot programmatically through COM:
import xlwings as xw
def build_pivot(path, dest, data_sheet="Data", pivot_sheet="Pivot"):
"""Create a real pivot table by driving Excel. Windows only."""
app = xw.App(visible=False)
try:
book = app.books.open(path)
source = book.sheets[data_sheet]
used = source.used_range
if pivot_sheet in [s.name for s in book.sheets]:
book.sheets[pivot_sheet].delete()
target = book.sheets.add(pivot_sheet, after=source)
cache = book.api.PivotCaches().Create(
SourceType=1, # xlDatabase
SourceData=used.api,
)
table = cache.CreatePivotTable(
TableDestination=target.range("A3").api,
TableName="RevenuePivot",
)
table.PivotFields("region").Orientation = 1 # xlRowField
table.PivotFields("product").Orientation = 2 # xlColumnField
revenue = table.PivotFields("revenue")
revenue.Orientation = 4 # xlDataField
revenue.Function = -4157 # xlSum
revenue.NumberFormat = "#,##0.00"
book.save(dest)
return dest
finally:
app.quit()
The finally block is not optional. An unhandled exception without it leaves an invisible Excel process running and holding the file, and a scheduled job that fails a few times accumulates them until the machine runs out of memory. The wider xlwings model is in reading and writing a live Excel workbook with xlwings.
Those magic numbers are Excel's own enumeration constants — 1 for a row field, 2 for a column field, 4 for a data field, -4157 for sum. They are stable across versions, but naming them makes the code readable:
ROW_FIELD, COLUMN_FIELD, DATA_FIELD = 1, 2, 4
SUM, COUNT, AVERAGE = -4157, -4112, -4106
Step 4 — The portable static route
When the job runs on Linux, or you simply do not want a dependency on Excel, produce a formatted static pivot alongside the raw data. The reader loses interactivity and gains a report that works everywhere:
import pandas as pd
def static_pivot_workbook(df, path):
"""A formatted summary plus the full source table — portable everywhere."""
pivot = pd.pivot_table(
df, index="region", columns="product", values="revenue",
aggfunc="sum", margins=True, margins_name="Total",
).round(2)
with pd.ExcelWriter(path, engine="xlsxwriter",
datetime_format="yyyy-mm-dd") as writer:
pivot.to_excel(writer, sheet_name="Summary")
df.to_excel(writer, sheet_name="Data", index=False)
book = writer.book
money = book.add_format({"num_format": "#,##0.00"})
header = book.add_format({"bold": True, "bg_color": "#EEF2FF",
"border": 1})
total = book.add_format({"bold": True, "num_format": "#,##0.00",
"top": 1})
summary = writer.sheets["Summary"]
summary.set_column("A:A", 16)
summary.set_column(1, len(pivot.columns), 15, money)
summary.set_row(0, None, header)
summary.set_row(len(pivot), None, total)
summary.freeze_panes(1, 1)
data = writer.sheets["Data"]
data.add_table(0, 0, len(df), len(df.columns) - 1,
{"name": "SalesData",
"columns": [{"header": str(c)} for c in df.columns],
"style": "Table Style Medium 2"})
data.set_column("A:C", 14)
data.set_column("D:D", 14, money)
return path
static_pivot_workbook(sales, "sales_summary.xlsx")
Shipping both sheets is the point. The summary answers the question most readers have, and the table lets the one reader who wants to slice it differently insert their own pivot without asking you for a new report. The pandas pivot mechanics are covered in creating a pivot table from Excel with pandas.
Common pitfalls and fixes
| Symptom | Cause | Fix |
|---|---|---|
| No way to create a pivot in openpyxl | It cannot build the cache | Use a template, xlwings, or a static pivot. |
| Pivot shows stale numbers | Cache not refreshed | Set pivot.cache.refreshOnLoad = True. |
| Pivot misses the newest rows | Source is a fixed range | Point it at a named table and grow the ref. |
| Excel processes accumulate | xlwings App not quit on error | Quit in a finally block. |
| Pivot source reference broken | Data sheet or table renamed | Keep the template's structure fixed. |
pandas pivot has a MultiIndex header | Multiple values or columns | Flatten before writing, or accept two header rows. |
| Works locally, fails in the container | xlwings needs Windows and Excel | Use the static or template route. |
Performance and scale notes
The three routes have very different cost profiles, and the difference is not subtle.
The static route is pure pandas — one vectorised aggregation and one write, so a million source rows summarise in seconds. It is also the only route with no external process.
The template route costs one openpyxl load and save of the whole workbook. That is fine for tens of thousands of rows and slow beyond that, because openpyxl holds everything in memory. Where the source is genuinely large, write the data sheet with the streaming approach in writing large DataFrames with write-only mode — though note that streaming mode cannot carry an existing pivot, so the pivot has to live in a separate workbook that references the data one.
The xlwings route is the slowest by a wide margin, because every property assignment is a COM round trip. Two mitigations matter:
import xlwings as xw
app = xw.App(visible=False)
app.screen_updating = False # do not repaint after every change
app.display_alerts = False # no modal dialogs to block a batch job
try:
...
finally:
app.screen_updating = True
app.quit()
And write the source data with pandas or xlsxwriter before opening Excel, rather than assigning cell values through COM — pushing a hundred thousand rows across the boundary one range at a time is orders of magnitude slower than writing the file and opening it.
The practical conclusion for most reporting pipelines: build the template once by hand, and let the scheduled job take the template route. It gives readers a live pivot, runs on a server, and costs a single workbook round trip.
Conclusion
openpyxl cannot create a pivot table from nothing, so the question is which of three routes fits your constraints. A template that already contains the pivot is usually the best answer: the script writes rows, grows the table reference, and sets refreshOnLoad so Excel rebuilds the cache when a reader opens the file — interactive for the reader, portable for the server. xlwings builds a pivot from scratch but ties the job to Windows with Excel. And where neither applies, ship a formatted static pivot alongside the full source as a named table, so anyone who wants to slice it differently can insert their own.
Frequently asked questions
Can openpyxl create a pivot table from scratch? No. openpyxl can read and preserve a pivot table that already exists in a workbook, and it can mark the cache to refresh on open, but it cannot construct a new pivot definition and its cache from nothing.
What is the difference between a native pivot and a pandas pivot? A native pivot stays interactive — the reader can drag fields, change the aggregation and refresh against new data. A pandas pivot is a static grid of values, which is fine for a printed report and useless to somebody who wants to explore.
Does the xlwings approach work on a server? No. It drives a real Excel instance through COM, so it needs Windows with Excel installed. On Linux or in a container you must use the template or static approaches instead.
How do I make a pivot refresh when the file opens?
Set the pivot cache's refreshOnLoad flag. openpyxl can do this on an existing pivot, so a template carrying the pivot picks up new source rows the moment a reader opens the workbook.
Should the source data be a table or a plain range? A named Excel table. A table's reference grows automatically as rows are added, so the pivot's source never needs updating; a fixed range has to be rewritten every time the row count changes.
Related
- Up to the parent: Creating Pivot Tables from Excel Data — the pandas side of pivoting.
- Create a Pivot Table from Excel with pandas — the aggregation behind the static route.
- Export a pandas Pivot Table to Excel, Formatted — making the static grid presentable.
- Create an Excel Table with Python — the named table a pivot should point at.
- Populate an Excel Template Without Losing Formatting — the fill-only discipline the template route needs.