jarrah

Excel's 1,048,576-row limit: what your export library does when you cross it, and how to ship more rows

Excel's grid ends at row 1,048,576. That is a property of the file format since Excel 2007, not of your machine or your library, and no amount of memory raises it. What varies is what your library does on row 1,048,577, and the answer, measured across four of them, is that none of them stops you.

What each library does at row 1,048,577

One column, one row past the limit, on the same machine, and then the zip opened to see what actually landed in xl/worksheets/sheet1.xml:

  openpyxl, write_only       saves with no error     row 1048577 is in the file
  XlsxWriter, constant_memory  closes with no error    row 1048577 is NOT in the file
  ExcelJS, WorkbookWriter    commits with no error   row 1048577 is in the file
  SheetJS, writeFile         writes with no error    row 1048577 is in the file; !ref says A1:A1048577

Three of the four produce a workbook with a row the grid cannot hold. Excel has to repair such a file or refuse it, and either way the rows past the line are gone when it opens. The fourth, XlsxWriter, checks the limit and then tells you in the quietest way a library can: write_number returns -1, which no example code ever checks, and raises no warning. The row simply never existed.

So the failure is discovered by whoever opens the file, which is the worst place for it: your export job reported success, the file is on the share, and a customer is reading a table that ends somewhere short of the truth.

Fix 1: split across worksheets

A workbook holds as many sheets as you like, each with its own 1,048,576 rows. The loop is the same shape in every library: count rows, start a new sheet at the limit, repeat the header. XlsxWriter, with constant_memory so the split does not undo the memory fix:

import xlsxwriter

LIMIT = 1_048_576 - 1                      # minus the header row

wb = xlsxwriter.Workbook("orders.xlsx", {"constant_memory": True})
header = ["Customer", "Amount", "Issued", "Status"]

sheet_no, row = 0, LIMIT                   # forces a new sheet on the first row
for order in orders:                       # a cursor, not a list
    if row == LIMIT:
        sheet_no += 1
        ws = wb.add_worksheet(f"Orders {sheet_no}")
        ws.write_row(0, 0, header)
        row = 0
    row += 1
    ws.write_row(row, 0, [order.customer, order.amount, order.issued, order.status])

wb.close()

ExcelJS is the same loop with addWorksheet and commit(); openpyxl's is create_sheet and append. Name the sheets with a number and keep the header on each: a reader who lands on sheet three without a header has no idea what column B is.

Say in the file that it was split. A total on sheet one that only covers sheet one is a wrong number with a confident face. If there is a summary sheet, put it first and make it cover all of them.

Fix 2: ask whether it needs to be Excel

A million rows is not something a person reads. If the file is going into a pipeline, a BI tool, pandas, or anything that is not a human with a mouse, CSV has no row limit, writes in a fraction of the time, and every consumer reads it. Parquet is smaller and typed. The question is worth asking before the split is written, because the answer is often that the spreadsheet was never the point and the row limit was the format telling you so.

Fix 3: do not ship the rows

The export that hits the limit is usually a raw table somebody asked for because the summary they wanted did not exist. A sheet of monthly totals with the raw rows in a CSV beside it serves the reader better than a workbook they cannot scroll, and it is the only version that stays under the limit as the data grows. This is the fix least often taken and the one that lasts.

What we would do

Split across sheets with XlsxWriter, or with ExcelJS's streaming writer in Node. This page is the one where Jarrah does nothing the libraries cannot, and it would be dishonest to pretend otherwise.

What Jarrah does differently is refuse, early and by name, rather than write a file that will not open. A JSON submission with too many rows in one sheet is rejected before anything is rendered, with a JSON Pointer to the sheet:

{
  "type": "https://docs.jarrah.sh/errors/invalid-workbook",
  "title": "Workbook failed validation",
  "status": 422,
  "errors": [
    { "pointer": "/sheets/0/rows",
      "message": "1048577 rows including the header; a worksheet holds at most 1048576" }
  ]
}

Splitting is then the same five lines in the body you build: several entries in sheets, a million rows each, one request. A streamed NDJSON job goes into one sheet, so a stream past the limit has to be cut into separate jobs on your side; that job is accepted and then fails on its first attempt with the row named. When this page was first measured it took three attempts, a bug in our queue that the measurement found and that has since been fixed. For the size of file Jarrah will build without holding it in your process, see the other pages here. For this limit, we share it with everyone.

What each option costs you

1,048,577 rows x 1 column, which is one row past the grid, Node 22.23 and Python 3.13 on the same machine. Memory and time are what your own process spends: the numbers you can reproduce on your machine.

approachpeak memorytime before it breaksformulas & charts
openpyxl write_only37 MB10.6swrites row 1,048,577; file is unopenableyes
XlsxWriter constant_memory20 MB4.4sdrops row 1,048,577; returns -1, no warningyes
ExcelJS WorkbookWriter208 MB2.1swrites row 1,048,577; file is unopenableformulas; no charts
SheetJS writeFile949 MB3.7swrites row 1,048,577; file is unopenableformulas; no charts
Jarrah, JSON or NDJSON——refuses: 422 naming the sheet, or a failed job naming the rowyes

The Jarrah row was measured against production with a test key: a JSON submission is refused before anything is rendered, with the pointer /sheets/0/rows and the message 1048577 rows including the header; a worksheet holds at most 1048576. A streamed job is accepted, then fails with sheet "Big", row 1048576, column 0: Row or column exceeds Excel's allowed limits. When first measured it took three attempts to say so, a validation failure retried as if it were transient; since 2026-10-23 it fails on the first.

Where this is the wrong choice. This is the one page where Jarrah does nothing the libraries cannot. The fix is to split rows across worksheets, which is a five-line loop in any of them and exactly the same five lines in a Jarrah JSON body. For a streamed job it is worse: NDJSON rows go into one sheet, so a stream of more than 1,048,576 rows has to be cut into separate jobs on your side. Jarrah earns its place on the size of the file it will build and on not holding it in your process, not on this limit, which it shares.