jarrah

openpyxl: MemoryError, or the worker killed, saving a large workbook

openpyxl builds the whole workbook as Python objects and serialises it when you call save(). A MemoryError, or more often a worker that simply disappears with Killed in the log, is not a leak or a bug in your loop. It is the library working as designed, on more rows than the process has memory for, and openpyxl ships the fix itself.

Why it happens

Every value you write becomes a Cell: an object with its value, its row and column, a style index and a reference back to the worksheet. The worksheet keeps them in a dictionary keyed by coordinate. None of it can be released before save(), because the writer needs the complete sheet to compute dimensions and the shared string table before it emits a byte of XML.

That works out at roughly 1.5 KB per row of four plain cells, about 370 bytes a cell. Measured on Python 3.13 with openpyxl 3.1.5, writing a string, a float, a date string and a status:

  10,000 rows     51 MB peak    0.4s
  20,000 rows     64 MB peak    0.7s
  50,000 rows    110 MB peak    1.9s
 100,000 rows    184 MB peak    3.7s
 200,000 rows    331 MB peak    7.4s

Python with openpyxl imported and nothing else is 37 MB, so the growth is real and it is linear. Python itself has no memory limit, so where this fails depends on where the process runs. At that rate a 512 MB container has room for roughly 300,000 rows and a 2 GB Celery worker for a little over a million, and when the limit arrives it is the OOM killer that enforces it, taking every other task on that worker too, which is why the log so often shows nothing at all.

A wide sheet makes it worse in proportion. Twenty columns is five times the cells, so the ceiling is a fifth of the rows. Styling a cell does not add another object, because styles are interned, but a formula does, and so does a datetime.

Fix 1: write-only mode, which is already installed

openpyxl has a second kind of workbook that writes each row to a temporary file as you append it and keeps nothing:

from openpyxl import Workbook

wb = Workbook(write_only=True)
ws = wb.create_sheet("Invoices")           # create_sheet, not wb.active
ws.append(["Customer", "Amount", "Issued", "Status"])

for invoice in invoices:                  # a cursor, not a list
    ws.append([invoice.customer, invoice.amount, invoice.issued, invoice.status])

wb.save("invoices.xlsx")

Same data, same machine:

  Workbook()              200,000 rows   331 MB   7.4s
  write_only=True         200,000 rows    38 MB   7.6s
  write_only=True       1,000,000 rows    38 MB  39.2s

Memory is flat because nothing accumulates: a million rows costs the same 38 MB as fifty thousand, and 37 of that is the interpreter. The time does not improve, because the per-cell work is the same; it just no longer has to fit.

The trade is that the sheet is write-only in the literal sense. There is no ws.cell(), no ws["A1"], no reading back a row you have appended, and rows go in order. Styles are applied to a WriteOnlyCell before it is appended rather than to a coordinate afterwards. Column widths and freeze panes are set before the first row. Charts and images still work. If your code computes a totals row by reading the cells above it, compute the total as you go instead.

Fix 2: XlsxWriter, if you are only writing

If the file is new, which an export always is, XlsxWriter is faster than openpyxl at the same job and has the same trick under a different name:

pip install xlsxwriter
import xlsxwriter

wb = xlsxwriter.Workbook("invoices.xlsx", {"constant_memory": True})
ws = wb.add_worksheet("Invoices")
money = wb.add_format({"num_format": "#,##0.00"})

ws.write_row(0, 0, ["Customer", "Amount", "Issued", "Status"])
for i, invoice in enumerate(invoices, start=1):
    ws.write_row(i, 0, [invoice.customer, invoice.amount, invoice.issued, invoice.status])
    ws.write_number(i, 1, invoice.amount, money)

wb.close()
  XlsxWriter, default         200,000 rows   163 MB    3.3s
  XlsxWriter, default       1,000,000 rows   717 MB   17.0s
  constant_memory             200,000 rows    20 MB    3.4s
  constant_memory           1,000,000 rows    20 MB   17.3s

The default mode is not flat either. XlsxWriter buffers every row until close(), at about 0.7 KB a row: less than openpyxl, still linear, and 717 MB at a million rows. The flag is what makes it flat, not the library.

constant_memory writes a row the moment you start the next one, so rows must be written top to bottom and a write to an earlier row is discarded without a word. Formats, formulas, charts and merged cells all work. XlsxWriter cannot open an existing file at all, which is the reason to stay on openpyxl if you are editing a template rather than producing a new file.

Fix 3: move it off the request

Whichever writer you use, a large export should not run inside a web request. Seven seconds of CPU in a Django or Flask view is a gunicorn worker held hostage, and three people clicking at once is the pool. Queue it, write to storage, and hand back a link.

What we would do

If the export is a new file and a data table, use XlsxWriter with constant_memory. If you have to stay on openpyxl, use write_only. Either is the honest answer and costs an hour.

If the same export has to come out of a Python service and a Node one identically, or the formatting is elaborate enough that you would rather describe it than write it, or you would rather not own the memory problem at all, that is what Jarrah is for. Post the rows and get back a styled .xlsx built outside your process.

Write them as NDJSON: a header line describing the workbook, then one line per row, from a generator so your side never holds more than one row either:

import json, os, requests

def ndjson(invoices):
    yield (json.dumps({"sheets": [{"name": "Invoices", "columns": [
        {"header": "Customer"},
        {"header": "Amount", "style": "money"},
        {"header": "Issued"},
        {"header": "Status"},
    ]}]}) + "\n").encode()
    for invoice in invoices:              # a cursor, or a generator
        yield (json.dumps([invoice.customer, invoice.amount,
                           invoice.issued, invoice.status]) + "\n").encode()

job = requests.post(
    "https://api.jarrah.sh/v1/jobs",
    data=ndjson(invoices),                # a generator: requests sends it chunked
    headers={
        "Authorization": f"Bearer {os.environ['JARRAH_KEY']}",
        "Content-Type": "application/x-ndjson",
    },
).json()                                  # poll /v1/jobs/{id}, or take a webhook

requests sends a generator body with chunked transfer encoding, one yield at a time. Measured against a local sink: 28 MB at 200,000 rows, and still 28 MB at a million.

For small exports there is a synchronous endpoint that hands the file straight back. See the quickstart. It has a per-plan cell ceiling, so a 200,000-row sheet takes the streaming path above.

What each option costs you

200,000 rows x 4 columns, Python 3.13 with openpyxl 3.1.5 and XlsxWriter 3.2.9, a standalone script with no framework. Python with openpyxl imported is 37 MB before anything runs, and that is inside every figure. Memory and time are what your own process spends: the numbers you can reproduce on your machine.

approachpeak memorytime before it breaksformulas & charts
openpyxl Workbook()331 MB7.4s~1.5 KB a row; a GB buys ~700,000yes
openpyxl write_only=True38 MB7.6s1,000,000+ (still 38 MB)yes, if styled before append
XlsxWriter163 MB3.3s~0.7 KB a row; a GB buys ~1,400,000yes
XlsxWriter + constant_memory20 MB3.4s1,000,000+ (still 20 MB)yes, in row order
Jarrah, rows streamed as NDJSON28 MB1.6s + the requestno limit you tune (1,000,000 at 28 MB)yes

Jarrah's figures are what your process spends writing the rows into the request, measured against a local sink: 28 MB at 50,000 rows, 200,000 and a million alike. The workbook is then built on our side, which is a network round trip you are paying for and is not in that column.

Where this is the wrong choice. If your export is a data table and you are writing a new file, XlsxWriter with constant_memory wins and it is not close: a flat 20 MB, twice openpyxl's speed, charts and formats included, no network dependency, and one keyword argument to turn on. openpyxl's write_only mode is the answer if you must stay on openpyxl. Jarrah earns its place when the same export has to come out of two services identically, when the formatting is elaborate enough that you would rather describe it than code it, or when you would rather not own the memory problem at all.