Guide
Formatting And Charting Excel Reports With PythonDeep dive

Write a Million Rows to Excel with xlsxwriter

Constant memory mode explained: what it costs, why rows must be written in order, where the temporary files go, how to split past Excel's 1,048,576-row limit, and when a CSV is the honest answer.

There is a point where writing an Excel file stops being a formatting problem and becomes a memory problem. In its default mode, xlsxwriter holds every written cell until close(), which is fast and flexible up to a few hundred thousand rows and then starts consuming gigabytes. constant_memory mode changes that: each row is serialised as soon as the next one begins, so peak memory stays flat whether the sheet has ten thousand rows or a million.

The mode costs something real, and knowing exactly what makes the difference between using it well and fighting it. This guide, part of Building Excel Reports with xlsxwriter, covers the trade, the write-order rules, the format's hard limits, and the question worth asking before any of it.

Memory against rows written, in each mode In default mode memory rises steadily with the number of rows written, because every cell object is retained until the workbook is closed. In constant memory mode it stays flat at roughly the size of a single row, because each row is flushed to a temporary file as soon as the next one starts. Peak memory as the row count grows high low 10k 100k 500k 1M rows default mode constant_memory The flat line is bought with one restriction: rows must be written in order and never revisited

Prerequisites

Bash
pip install xlsxwriter

You also want somewhere with free disk space for the temporary files — constant-memory mode trades RAM for a spooled temporary directory, and a full /tmp is the most common way this fails on a server.

Step 1: Turn the mode on

It is a workbook option, set at construction:

Python
import xlsxwriter

wb = xlsxwriter.Workbook("detail.xlsx", {
    "constant_memory": True,
    "tmpdir": "/var/tmp",              # somewhere with room; defaults to the system temp
    "default_date_format": "yyyy-mm-dd",
})
ws = wb.add_worksheet("Detail")

money = wb.add_format({"num_format": '#,##0.00'})
ws.set_column("A:A", 12)
ws.set_column("B:B", 18)
ws.set_column("C:C", 14, money)        # column formats still work

ws.write_row(0, 0, ["order_id", "region", "amount"])

for i, (oid, region, amount) in enumerate(source_rows(), start=1):
    ws.write_number(i, 0, oid)
    ws.write_string(i, 1, region)
    ws.write_number(i, 2, amount)

wb.close()

set_column is still available and still cheap, which matters more here than in the default mode: with no ability to revisit cells, column-level formatting is the main styling tool you have left. default_date_format is worth setting for the same reason — it applies to every date written without an explicit format, saving you from passing one on every call.

Step 2: Respect the write order

The rule is simple and unforgiving: rows must be written in increasing order, and once you start row n+1, row n is gone.

Python
ws.write(0, 0, "header")
ws.write(1, 0, "first")
ws.write(2, 0, "second")
ws.write(1, 1, "late")          # silently lost — row 1 was already flushed
Which writes are still allowed once a row has been flushed Writing further cells in the current row is allowed. Starting the next row is allowed and flushes the previous one. Writing to a different worksheet is allowed, because each sheet tracks its own current row. Writing back to a row already flushed is silently discarded — there is no error to catch. The current row is row 500 on sheet "Detail" more of row 500 any column, any order allowed row 501 flushes row 500 to the spool allowed sheet "Summary" its own current row, unaffected allowed row 499 already written out and freed silently discarded

There is no exception and no warning. In practice this rules out three habits that are normal in the default mode:

  • A total row at the top. Compute it first from the source data, or put it on a separate summary sheet written before the detail sheet.
  • Measured column widths. You cannot look at the data and then size the columns, because by the time you have seen it the rows are written. Size from the schema — a known-length ID, a currency column — or take one pass over a sample to estimate.
  • Post-hoc highlighting. Decide the format as each row is written, from the values you already have in hand.

Within a row, cells can be written in any column order; it is only the row index that must not go backwards. Writing to a different sheet is fine at any time — each worksheet keeps its own current row — which is exactly what makes the summary-sheet pattern work.

Step 3: Put the totals somewhere they can exist

Because the detail sheet cannot be revisited, accumulate as you stream and write the summary afterwards on its own sheet:

Python
from collections import defaultdict

wb = xlsxwriter.Workbook("detail.xlsx", {"constant_memory": True})
detail = wb.add_worksheet("Detail")
summary = wb.add_worksheet("Summary")          # created now, written later

bold = wb.add_format({"bold": True})
money = wb.add_format({"num_format": '#,##0.00'})

detail.write_row(0, 0, ["order_id", "region", "amount"], bold)
detail.set_column("C:C", 14, money)

totals, count = defaultdict(float), 0
for i, (oid, region, amount) in enumerate(source_rows(), start=1):
    detail.write_number(i, 0, oid)
    detail.write_string(i, 1, region)
    detail.write_number(i, 2, amount)
    totals[region] += amount
    count += 1

summary.write_row(0, 0, ["Region", "Amount"], bold)
summary.set_column("B:B", 16, money)
for r, (region, total) in enumerate(sorted(totals.items()), start=1):
    summary.write_string(r, 0, region)
    summary.write_number(r, 1, total)
summary.write_string(len(totals) + 2, 0, f"{count:,} detail rows", bold)

wb.close()

A dictionary of running totals costs a few kilobytes regardless of how many rows pass through it, so the summary is effectively free. This is also better reporting than a total row on a million-row sheet, which nobody will ever scroll to.

Step 4: Handle the format's hard limits

A worksheet holds at most 1,048,576 rows and 16,384 columns. That is the .xlsx specification, not a library restriction, so the only answer is to split:

Python
ROWS_PER_SHEET = 1_000_000          # leave headroom below the 1,048,576 limit


def write_split(rows, path, headers):
    wb = xlsxwriter.Workbook(path, {"constant_memory": True})
    bold = wb.add_format({"bold": True})
    ws, sheet_no, row_no = None, 0, 0

    for row in rows:
        if ws is None or row_no > ROWS_PER_SHEET:
            sheet_no += 1
            ws = wb.add_worksheet(f"Detail {sheet_no}")
            ws.write_row(0, 0, headers, bold)
            ws.set_column(0, len(headers) - 1, 16)
            row_no = 1
        ws.write_row(row_no, 0, row)
        row_no += 1

    wb.close()
    return sheet_no

Splitting across sheets keeps everything in one file, which is convenient but produces a workbook that takes a long time to open. Splitting across files — one per region, per month, per whatever the reader actually filters by — is nearly always the better experience, and it is what Generate One Excel Report per Region in a Loop covers.

Three ways to handle more rows than one sheet can hold Splitting across sheets keeps one file but makes it slow to open. Splitting across files by the dimension readers filter on gives each person a workbook they can actually use. Writing a summary workbook plus a CSV or Parquet detail file is the option that stays fast at any size. Past about a million rows, the question is who opens it and how split across sheets one file, Detail 1..n nothing to reassemble slow to open, and a filter only sees one sheet at a time split across files one per region or month each opens instantly matches how people already ask for the data summary + flat file small .xlsx to read CSV or Parquet detail stays fast at any size — the usual right answer

Step 5: Know where the time goes

Constant-memory mode fixes memory, not speed. At a million rows the cost breaks down roughly like this:

StageWhat dominatesWhat helps
Producing the rowsThe query or the source readStream from the database; do not build a DataFrame first
write_* callsPython-level per-cell workwrite_row over per-cell calls; avoid per-cell formats
SerialisingXML generation and the temp spoolA fast tmpdir; fewer columns
Zipping at close()Compression of the sheet XMLUnavoidable; it is a large share of the total

The practical lever is columns, not rows: dropping four unused columns from a million-row export removes four million cells and takes a proportional slice off every stage. The second lever is write_row over three separate write_number calls, which cuts the Python-side overhead noticeably at this scale.

If the source is a database, stream it rather than materialising it — Export SQL Query Results to Excel with Python covers stream_results and chunked reads, and the two techniques compose: a chunked read feeding a constant-memory write holds neither side in memory.

Common pitfalls and gotchas

SymptomCauseFix
Cells silently missingA row written after moving past itWrite strictly top to bottom
Worksheet row or column index out of rangePast 1,048,576 rows or 16,384 columnsSplit sheets or files
No space left on device at close()Temp spool filled the diskSet tmpdir to a large volume
Memory still growsconstant_memory not actually setPass it in the options dict at construction
Column widths look wrongCannot measure after writingSize from the schema, or sample first
The file will not openclose() never ranUse the workbook as a context manager
Excel takes minutes to open itThe row count itselfShip a summary plus a flat file
Totals missing from the detail sheetCannot revisit the topAccumulate while streaming, write a summary sheet

Performance and scale notes

As a rule of thumb on ordinary hardware, a million rows of three or four simple columns writes in the low tens of seconds with flat memory in the tens of megabytes; the same write in the default mode is faster per row but climbs into gigabytes and eventually swaps, at which point it is slower by a wide margin. The crossover where constant memory starts winning is usually somewhere between 200,000 and 500,000 rows, depending on column count and how much formatting is attached.

The honest scale note, though, is about the reader. A one-million-row .xlsx takes a long time to open, makes filtering sluggish and cannot be emailed. Before optimising the write, check whether anyone opens the detail at all — Convert Excel to CSV with Python covers the flat-file alternative, and a summary workbook alongside it is almost always what people actually wanted.

Conclusion

constant_memory makes a workbook of any size writable in flat memory, at the cost of random access: rows go out in order and never come back. Design around that — column formats instead of per-cell styling, totals accumulated while streaming and written to a summary sheet, widths chosen from the schema — and give the spool a tmpdir with room. Then step back and check the file is one anybody can open, because splitting by the dimension people filter on, or shipping a summary plus a flat file, usually beats the million-row sheet you were about to generate.

Frequently asked questions

What is the row limit for one sheet? 1,048,576 rows and 16,384 columns — a hard limit of the .xlsx format, not of any library. Past that you must split across sheets or files.

What exactly does constant_memory give up? Random access. Each row is serialised when the next one starts, so rows must be written top to bottom and a written row cannot be revisited — no late totals, no measured column widths, no going back.

Can I still format cells in constant memory mode? Yes, at write time. Column formats via set_column and per-cell formats passed to the write call both work; what you cannot do is restyle a row after moving past it.

Should I be writing a million rows to Excel at all? Usually not. A workbook that large is slow to open and unusable to filter. Ship a summary workbook plus a CSV or Parquet file for the detail, and reserve the giant sheet for cases where a tool downstream genuinely requires .xlsx.

Up to the parent guide:

Related guides: