Write Large DataFrames to Excel with write_only Mode
Generating a large workbook has the same problem as reading one, in reverse: the default path builds every cell in memory before anything reaches disk. openpyxl's write_only mode streams instead, appending each row to the file as it is produced. This guide, part of Working with Large Excel Files in Python, covers the mode, its constraints, the pandas bridge, and the xlsxwriter alternative.
Prerequisites
pip install pandas openpyxl xlsxwriter
The minimal streaming write
from openpyxl import Workbook
wb = Workbook(write_only=True)
ws = wb.create_sheet("Export")
ws.append(["Order_ID", "Region", "Revenue"])
for order_id in range(1, 300_001):
ws.append([order_id, "North", round(order_id * 1.5, 2)])
wb.save("streamed.xlsx")
print("done")
Two differences from ordinary openpyxl matter immediately. Workbook(write_only=True) starts with no sheets at all, so create_sheet is mandatory — wb.active is None. And the only supported operation on the sheet is append: there is no ws["A1"], no ws.cell(...), and no way to go back.
Feeding it a DataFrame
to_excel needs a normal worksheet, so stream the frame yourself. itertuples is the fast iterator — it avoids building a Series per row:
import numpy as np
import pandas as pd
from openpyxl import Workbook
rng = np.random.default_rng(1)
df = pd.DataFrame({
"Order_ID": np.arange(1, 250_001),
"Region": rng.choice(["North", "South", "West"], 250_000),
"Quantity": rng.integers(1, 20, 250_000),
"Unit_Price": rng.uniform(5, 200, 250_000).round(2),
})
df["Revenue"] = (df["Quantity"] * df["Unit_Price"]).round(2)
wb = Workbook(write_only=True)
ws = wb.create_sheet("Orders")
ws.append(list(df.columns))
for row in df.itertuples(index=False, name=None):
ws.append(list(row))
wb.save("orders_streamed.xlsx")
name=None gives plain tuples rather than named tuples, which is measurably quicker over a quarter of a million rows. If the frame itself is the memory problem, combine this with a chunked read so neither side is ever fully resident.
Styling as you go
Formatting is still possible, but it has to be attached to the cell at the moment its row is appended. WriteOnlyCell is the vehicle:
from openpyxl import Workbook
from openpyxl.cell import WriteOnlyCell
from openpyxl.styles import Font, PatternFill
wb = Workbook(write_only=True)
ws = wb.create_sheet("Orders")
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill("solid", start_color="4338CA")
header = []
for name in ["Order_ID", "Region", "Quantity", "Revenue"]:
cell = WriteOnlyCell(ws, value=name)
cell.font = header_font
cell.fill = header_fill
header.append(cell)
ws.append(header)
for order_id, region, qty, revenue in [(1, "North", 4, 79.96), (2, "South", 2, 99.0)]:
money = WriteOnlyCell(ws, value=revenue)
money.number_format = "#,##0.00"
ws.append([order_id, region, qty, money])
ws.freeze_panes = "A2" # sheet-level settings are fine
ws.column_dimensions["B"].width = 14 # set before the rows are flushed
wb.save("styled_stream.xlsx")
Reuse the Font and PatternFill objects rather than constructing them per row: openpyxl deduplicates identical styles, and a fresh object per cell inflates the workbook's style table, which is the very thing that makes large files slow to open.
Sheet-level properties such as freeze_panes and column widths work because they are not per-cell data — but set them before saving, since the sheet is finalised at that point.
The xlsxwriter alternative
xlsxwriter has an equivalent mode and is generally faster for pure generation. It also plugs into pandas directly, which is the reason to prefer it when you want to_excel convenience:
import pandas as pd
with pd.ExcelWriter(
"constant_memory.xlsx",
engine="xlsxwriter",
engine_kwargs={"options": {"constant_memory": True}},
) as writer:
df.to_excel(writer, sheet_name="Orders", index=False)
constant_memory flushes each row as it is written, with the same in-order constraint: once a row is flushed you cannot revisit it, so set_column widths and any conditional formatting must be declared before the data goes in. The upside is that pandas does the iteration for you.
Plan the things you cannot go back and add
Everything that would normally be done "afterwards" has to move before or beside the loop. Totals are the usual casualty:
from openpyxl import Workbook
rows = [(1, "North", 79.96), (2, "South", 99.0), (3, "West", 85.75)]
wb = Workbook(write_only=True)
ws = wb.create_sheet("Orders")
ws.append(["Order_ID", "Region", "Revenue"])
total = 0.0
for order_id, region, revenue in rows: # accumulate while streaming
total += revenue
ws.append([order_id, region, revenue])
ws.append([]) # spacer
ws.append(["", "Total", round(total, 2)]) # written last, as a value
wb.save("streamed_with_total.xlsx")
A =SUM(C2:C4) formula would work equally well here, because it can be appended as the last row — what you cannot do is put the total at the top. When a summary must appear first, write it to its own sheet and make that sheet active, which is a better layout anyway for a file with hundreds of thousands of detail rows.
Streaming from source to output
The two streaming modes compose. Reading a large workbook row by row and writing a transformed one row by row keeps peak memory at a couple of rows regardless of how big either file is:
from openpyxl import Workbook, load_workbook
src = load_workbook("large.xlsx", read_only=True, data_only=True)
out = Workbook(write_only=True)
try:
ws_in = src["Orders"]
ws_out = out.create_sheet("Enriched")
rows = ws_in.iter_rows(values_only=True)
header = list(next(rows))
qty, price = header.index("Quantity"), header.index("Unit_Price")
ws_out.append(header + ["Revenue"])
written = 0
for row in rows:
revenue = round((row[qty] or 0) * (row[price] or 0), 2)
ws_out.append(list(row) + [revenue])
written += 1
finally:
src.close()
out.save("enriched_streamed.xlsx")
print(f"{written:,} row(s) transformed without holding either sheet")
This is the shape most "add a calculated column to a huge export" jobs should take. Nothing accumulates, the transformation is a couple of arithmetic operations per row, and the job's memory profile is flat enough to run in a small container.
Common pitfalls and gotchas
| Symptom | Cause | Fix |
|---|---|---|
AttributeError: 'NoneType' object has no attribute 'append' | wb.active is None in write-only mode | Call wb.create_sheet(...) first |
Cell is read-only or index errors | Tried ws["A1"] or ws.cell(...) | Only append is supported |
| Styling ignored | Style applied after the row was appended | Build WriteOnlyCell objects before appending |
| Workbook still huge in memory | A style object created per cell | Reuse Font/PatternFill instances |
to_excel fails on the write-only book | pandas needs a normal worksheet | Iterate with itertuples, or use xlsxwriter |
| Totals cannot be added at the top | Rows cannot be revisited | Put the summary on its own sheet |
| Column widths missing | Set after save() | Set column_dimensions before saving |
Splitting output across sheets
A single sheet caps at 1,048,576 rows, and long before that a reader gives up. Write-only mode handles the split naturally, because a new sheet is just another create_sheet before the next batch of appends:
from openpyxl import Workbook
ROWS_PER_SHEET = 200_000
header = ["Order_ID", "Region", "Revenue"]
wb = Workbook(write_only=True)
ws = None
for i, row in enumerate(df.itertuples(index=False, name=None)):
if i % ROWS_PER_SHEET == 0:
ws = wb.create_sheet(f"Part_{i // ROWS_PER_SHEET + 1}")
ws.append(header)
ws.append(list(row))
wb.save("split_output.xlsx")
print("sheets:", wb.sheetnames)
Repeating the header on each part is what makes the split usable — a reader who opens Part_3 should not have to go back to Part_1 to learn what the columns are. If the consumer is a machine rather than a person, splitting into several files is usually kinder still, and each one can then be read independently.
Performance and scale notes
Streaming turns peak memory from "the whole sheet" into "one row plus the shared string table" — and that string table is the remaining growth term, so a column of a million distinct free-text values still costs. Categorical text is nearly free by comparison, since each distinct string is stored once. On raw speed, xlsxwriter in constant_memory mode is typically the fastest generator, openpyxl write_only close behind, and both are several times faster than the ordinary path once you are past a hundred thousand rows. If the output is only ever read by machines, writing CSV or Parquet instead is faster still by an order of magnitude.
Conclusion
Workbook(write_only=True) streams rows straight to disk, so generating a 300,000-row workbook costs roughly the memory of one row. In exchange you append in order, style with WriteOnlyCell at append time, reuse style objects, and plan anything that would normally be added afterwards. When the data is already a DataFrame and you want to_excel, xlsxwriter's constant_memory gives the same benefit with less code.
Frequently asked questions
Can I still style cells in write_only mode?
Yes, per cell, using WriteOnlyCell — but you must style each cell as you append its row, because rows cannot be revisited afterwards.
Can I use write_only with pandas to_excel?
Not directly. to_excel needs a normal worksheet. Either iterate the DataFrame yourself with itertuples, or use xlsxwriter's constant_memory option through ExcelWriter.
Does write_only work with an existing file?
No. A write-only workbook is created from scratch. To add a large sheet to an existing file, generate it separately and combine, or accept a normal load.
How much memory does it actually save? Peak memory becomes roughly one row plus the shared string table, instead of the whole sheet — the difference between hundreds of megabytes and a few.
Related
Up to the parent guide:
- Working with Large Excel Files in Python — reading, writing and format choices at scale.
Related guides:
- Speed Up openpyxl with read_only Mode — the same streaming idea on the way in.
- Write Multiple DataFrames to One Excel File — layout when the output is several sheets.
- openpyxl vs xlsxwriter vs pandas.ExcelWriter — how the engines differ.
- Convert Excel to CSV with Python — when the output does not need to be a workbook.