jarrah

pandas DataFrame.to_excel is slow, and runs out of memory on a large frame

to_excel is slow because it walks the frame in Python one cell at a time, and it is memory-hungry because the engine underneath buffers every cell until the file is closed. Which engine that is depends on what happens to be installed, which is why the same line of code behaves differently on two machines.

Why it happens

to_csv hands the whole frame to a C writer and is done. to_excel cannot: the format is a zip of XML documents, so pandas iterates the frame in Python, formats each value as a cell, and calls the engine for every one. Four columns by 200,000 rows is 800,000 calls before a byte is zipped.

Then the engine holds what it was given. openpyxl keeps a cell object per value until save(); XlsxWriter keeps the rows as lists until close(). Measured on Python 3.13, pandas 3.0.6, a frame of a string, a float, a date string and a status:

                               50,000 rows          200,000 rows
  the DataFrame alone           77 MB   0.02s       114 MB   0.1s
  to_excel, openpyxl           153 MB   2.3s        396 MB   9.7s
  to_excel, xlsxwriter         107 MB   1.5s        225 MB   6.2s
  to_csv                        81 MB   0.08s       115 MB   0.3s

Subtract the frame and the writer's own cost is about 1.4 KB per row through openpyxl and 0.55 KB through XlsxWriter. Neither has a row limit of its own; the limit is whatever memory the process has, and a worker that crosses it is killed rather than told. The traceback, if there is one, points at openpyxl/cell/cell.py or a zip write, which is where the last allocation happened rather than where the memory went.

You may not be using the engine you think. pandas' default for .xlsx is auto: XlsxWriter if it imports, openpyxl otherwise. A machine with both installed takes the faster path; the production image that only has openpyxl, because something reads Excel files with it, takes the slower one with almost twice the memory. Pass engine= explicitly and it stops depending on the environment.

Fix 1: pick the engine, and the mode

XlsxWriter has a constant_memory mode that writes each row to a temporary file as soon as the next row starts, so nothing accumulates. pandas passes it through engine_kwargs:

pip install xlsxwriter
with pd.ExcelWriter(
    "invoices.xlsx",
    engine="xlsxwriter",
    engine_kwargs={"options": {"constant_memory": True}},
) as writer:
    df.to_excel(writer, sheet_name="Invoices", index=False)

Same frame, same machine:

  to_excel, openpyxl                 200,000 rows   396 MB   9.7s
  to_excel, xlsxwriter               200,000 rows   225 MB   6.2s
  xlsxwriter + constant_memory       200,000 rows   114 MB   4.5s   (the frame, and nothing else)

Peak memory is now the DataFrame itself. The frame is the floor, because you built it before calling to_excel; nothing in the writer can get under it. The time improves too, because XlsxWriter's row path is cheaper than openpyxl's cell objects, but it is still seconds: the per-cell loop in pandas is the same regardless of engine.

The trade is that constant_memory writes rows in order and never looks back. to_excel itself is fine with that. Anything you do to the sheet afterwards through writer.sheets["Invoices"] that touches cells already written, such as autofitting columns from their contents or writing a totals row above the data, silently does nothing. Column widths, formats and freeze panes set before the data are fine.

Fix 2: ask whether it needs to be Excel

If the file is consumed by a program, a pipeline, or a person who will load it into pandas again, to_csv is thirty times faster and costs nothing over the frame. A surprising number of "Excel exports" are CSV with a worse extension. The question is worth asking before the engine is.

Fix 3: do not build the frame at all

If the data comes from a database and the frame exists only to be written out, the frame is the cost. Iterate the query with a server-side cursor and write rows straight into XlsxWriter with constant_memory, and the process holds one row. That is a different page, on openpyxl and XlsxWriter without pandas, where the same export ran at a flat 20 MB.

What we would do

If you have a frame and need an .xlsx, use XlsxWriter with constant_memory. That is the honest answer and it is one keyword argument.

If the file needs number formats, a chart, or a second sheet styled the way a finance team expects, to_excel will not produce it and you are about to write a hundred lines of XlsxWriter formatting code that lives in a data pipeline. That is what Jarrah is for. Hand it the rows and get back a styled .xlsx built outside your process.

Write them as NDJSON straight out of the frame, one row per line, so nothing beyond the frame is ever held:

import json, os, requests

def ndjson(df):
    yield (json.dumps({"sheets": [{"name": "Invoices", "columns": [
        {"header": "Customer"},
        {"header": "Amount", "style": "money"},
        {"header": "Issued"},
        {"header": "Status"},
    ]}]}) + "\n").encode()
    for record in df.itertuples(index=False, name=None):
        yield (json.dumps(record) + "\n").encode()

job = requests.post(
    "https://api.jarrah.sh/v1/jobs",
    data=ndjson(df),                      # 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: 123 MB at 200,000 rows, which is the frame plus the requests import and nothing that grows with the data. The two seconds are json.dumps on 800,000 values, which is the same per-cell loop as to_excel without the XML.

For small frames 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 pandas 3.0.6, openpyxl 3.1.5 and XlsxWriter 3.2.9. The DataFrame itself is 114 MB before any writer 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
to_excel, openpyxl engine396 MB9.7sthe frame + 1.4 KB a rowvalues only
to_excel, XlsxWriter engine225 MB6.2sthe frame + 0.55 KB a rowvalues only
XlsxWriter + constant_memory114 MB4.5sthe framevalues only
to_csv115 MB0.3sthe framenot a spreadsheet
Jarrah, rows streamed from itertuples123 MB1.9s + the requestthe frameyes

Jarrah's figure is what your process spends writing the rows into the request, measured against a local sink; it is the frame plus the requests library and nothing that grows. 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 the frame fits in memory, which it does or you would not have it, XlsxWriter with constant_memory is the right answer: one keyword argument, no new dependency, and peak memory is the frame you already paid for. If the consumer is a program rather than a person, to_csv is thirty times faster and should win outright. Jarrah earns its place when the file needs number formats, charts or a second styled sheet that to_excel will not produce, or when the same export has to look identical out of a Python service and a Node one.