How-to
Advanced Data Transformation And CleaningDeep dive

Export a Large Database Table to Excel in Chunks

Export a database table too big to load at once into Excel with flat memory use: stream rows in chunks from SQLAlchemy with a server-side cursor, write them straight to disk with XlsxWriter constant_memory mode, roll over to a new sheet before the 1,048,576-row limit, keep dates and numbers typed, and check the row counts and memory afterwards.

"Can you send me the whole table in Excel?" is a request that is easy to fulfil for ten thousand rows and surprisingly easy to get wrong for two million. The straightforward approach — pd.read_sql into a DataFrame, then to_excel — holds the entire result in memory twice over (once as the DataFrame, once as the workbook being built), and fails outright when the row count passes Excel's limit of 1,048,576 rows per sheet. The robust approach streams: fetch rows from the database in chunks, write each chunk to the workbook and discard it, and start a new sheet before the limit. With XlsxWriter's constant_memory mode, memory stays flat no matter how many rows go through. This page builds that export, with the database side done properly too. It belongs to Moving Data Between Excel and Databases.

Prerequisites

Bash
pip install pandas sqlalchemy xlsxwriter openpyxl

warehouse.db is a SQLite database with a movements table of 250,000 stock movements: id, timestamp, SKU, warehouse, quantity and unit cost. SQLite keeps the example self-contained; the same code works against PostgreSQL, SQL Server or MySQL by changing the connection URL, and the server-side cursor note in Step 1 matters most for those.

Step 1 — Fetch the table in chunks

pd.read_sql with chunksize returns an iterator of DataFrames instead of one large frame. With SQLAlchemy, add stream_results=True so the database driver uses a server-side cursor and does not pull the whole result into the client before the first chunk is returned:

Python
import pandas as pd
from sqlalchemy import create_engine, text

engine = create_engine("sqlite:///warehouse.db")
query = text("SELECT id, moved_at, sku, warehouse, qty, unit_cost FROM movements ORDER BY id")

with engine.connect().execution_options(stream_results=True) as conn:
    sizes = [len(chunk) for chunk in pd.read_sql(query, conn, chunksize=50_000)]
print(len(sizes), "chunks:", sizes)

Five chunks of 50,000 rows each arrive one at a time. The chunksize controls how many rows pandas builds into each DataFrame; stream_results=True controls whether the driver buffers the full result first. Without it, drivers such as psycopg for PostgreSQL fetch every row into client memory at the moment the query executes, and chunking in pandas only splits up data that is already all in memory. SQLite does not buffer that way, so the option is harmless here and essential there. Ordering by the primary key makes the export deterministic, which matters when someone compares two exports.

Step 2 — Write each chunk straight to disk

XlsxWriter's constant_memory mode writes each row to a temporary file as soon as the next row starts, instead of keeping the whole sheet in memory. The cost is that rows must be written in order, top to bottom — exactly what streaming chunks does:

Python
import xlsxwriter

def export(conn, query, path, chunksize=50_000, max_rows=1_048_575):
    book = xlsxwriter.Workbook(path, {"constant_memory": True})
    head = book.add_format({"bold": True, "bg_color": "#EEF2FF", "bottom": 1})
    when = book.add_format({"num_format": "yyyy-mm-dd hh:mm"})
    money = book.add_format({"num_format": "#,##0.00"})
    sheet, row, sheets, total = None, 0, 0, 0
    for chunk in pd.read_sql(query, conn, chunksize=chunksize, parse_dates=["moved_at"]):
        for rec in chunk.itertuples(index=False):
            if sheet is None or row > max_rows:
                sheets += 1
                sheet = book.add_worksheet(f"Movements {sheets}")
                sheet.write_row(0, 0, list(chunk.columns), head)
                sheet.set_column(1, 1, 17)
                sheet.freeze_panes(1, 0)
                row = 1
            sheet.write_number(row, 0, rec.id)
            sheet.write_datetime(row, 1, rec.moved_at.to_pydatetime(), when)
            sheet.write_string(row, 2, rec.sku)
            sheet.write_string(row, 3, rec.warehouse)
            sheet.write_number(row, 4, rec.qty)
            sheet.write_number(row, 5, rec.unit_cost, money)
            row += 1
            total += 1
    book.close()
    return sheets, total

with engine.connect().execution_options(stream_results=True) as conn:
    print(export(conn, query, "movements.xlsx"))

Each row is written with a typed method — write_datetime with a date format, write_number with a money format — so the timestamps arrive in Excel as real dates and the costs as numbers, not text. parse_dates makes pandas convert the SQLite text timestamps while reading. The header row, column width and frozen pane are set when each sheet is created, because in constant_memory mode nothing can be changed in a row once a later row has been written. Writing cell by cell looks slow, but XlsxWriter is fast at it; the generic to_excel would not work here anyway, because it cannot append to a sheet across chunks.

A streaming export from database to workbook The query runs with a server-side cursor, pandas yields chunks of rows, each row is written with a typed XlsxWriter call in constant memory mode and flushed to disk, and a new sheet starts before Excel's row limit. Server-side cursor stream_results=True Chunks read_sql(chunksize=…) constant_memory rows flushed in order Sheet rollover before 1,048,576 rows

Step 3 — Roll over before the row limit

An Excel sheet holds at most 1,048,576 rows, header included. The export function counts rows per sheet and starts a new sheet with its own header when the next row would exceed the limit. To see the rollover without generating a million rows, lower the limit:

Python
with engine.connect().execution_options(stream_results=True) as conn:
    sheets, total = export(conn, query, "movements_split.xlsx", max_rows=100_000)
print(sheets, "sheets for", total, "rows")
Three sheets from one table With a limit of 100,000 data rows per sheet, the 250,000 movements are written to Movements 1 with ids 1 to 100,000, Movements 2 with ids 100,001 to 200,000 and Movements 3 with ids 200,001 to 250,000. Each sheet has its own header row. A B C D 1 Sheet First id Last id Data rows 2 Movements 1 1 100,000 100,000 3 Movements 2 100,001 200,000 100,000 4 Movements 3 200,001 250,000 50,000

With a limit of 100,000 data rows, the 250,000 movements land on three sheets — 100,000, 100,000 and 50,000 rows — each with a header and frozen top row. In real use leave the default; the function then only splits tables that genuinely exceed one sheet. For very large exports ask whether Excel is the right format at all: a 3-million-row workbook is slow to open and hard to use, and a CSV or Parquet file, or a filtered extract, usually serves the requester better. Excel vs CSV vs Parquet for Python Data Pipelines helps with that conversation.

Step 4 — Measure memory against the simple approach

The point of streaming is flat memory. tracemalloc measures the peak memory Python allocates during each approach:

Python
import tracemalloc

def peak_mb(fn):
    tracemalloc.start()
    fn()
    peak = tracemalloc.get_traced_memory()[1]
    tracemalloc.stop()
    return round(peak / 1024 / 1024, 1)

def simple():
    df = pd.read_sql(query, engine, parse_dates=["moved_at"])
    df.to_excel("movements_simple.xlsx", index=False, engine="xlsxwriter")

def streamed():
    with engine.connect().execution_options(stream_results=True) as conn:
        export(conn, query, "movements_streamed.xlsx")

print("simple:   ", peak_mb(simple), "MB peak")
print("streamed: ", peak_mb(streamed), "MB peak")

The simple approach peaked at about 237 MB for this 250,000-row table — the full DataFrame plus XlsxWriter's in-memory sheet — and grows linearly with the table. The streamed export peaked at about 39 MB, set mainly by the 50,000-row chunk, and stays there however long the table is. On a server running several jobs, or in a container with a memory limit, that difference decides whether the export finishes at all. Smaller chunks lower the peak further at a small cost in speed; 10,000 to 100,000 rows per chunk is a sensible range.

Peak Python memory exporting 250,000 rows Reading the whole table into a DataFrame and writing it with to_excel peaked at about 237 megabytes in this run. Streaming in chunks of 50,000 rows with constant memory mode peaked at about 39 megabytes and does not grow with the table. read_sql + to_excel ≈ 237 MB, grows with rows chunks + constant_memory ≈ 39 MB, flat

Step 5 — Check the export is complete

A large export should prove it contains every row. Count rows per sheet in the written file without loading it fully, and compare with the database:

Python
from openpyxl import load_workbook

with engine.connect() as conn:
    expected = conn.execute(text("SELECT COUNT(*) FROM movements")).scalar()
wb = load_workbook("movements_split.xlsx", read_only=True)
per_sheet = {ws.title: sum(1 for _ in ws.iter_rows(min_row=2, values_only=True)) for ws in wb.worksheets}
wb.close()
print(per_sheet, "| total", sum(per_sheet.values()), "| database", expected)
wb = load_workbook("movements_split.xlsx", read_only=True)
first = next(wb.worksheets[0].iter_rows(min_row=2, max_row=2, values_only=True))
wb.close()
print("first row types:", [type(v).__name__ for v in first])

Reading with read_only=True streams the sheets, so even a very large export can be checked cheaply. The total across sheets matches the database count, and the first data row comes back with an integer, a datetime, strings and numbers — proof the cells were written typed. If rows are being added to the table while the export runs, the count can legitimately differ; export from a snapshot or with a fixed WHERE id <= :max_id to make the comparison exact. Counting rows quickly is covered in Count Rows in an Excel File Without Loading It.

Common pitfalls

  • Chunking in pandas but not in the driver. Without stream_results=True, the driver may load everything first.
  • to_excel per chunk. It cannot append to a sheet; write rows yourself with XlsxWriter.
  • Formatting rows after writing more rows. In constant_memory mode, set headers and widths when the sheet is created.
  • Text instead of dates and numbers. Use write_datetime and write_number with formats.
  • Ignoring the row limit. Roll over to a new sheet before 1,048,576 rows — or question Excel as the format.

Conclusion

To export a table too big for memory, stream it: read with pd.read_sql(..., chunksize=...) over a connection with stream_results=True so the database uses a server-side cursor, write each row with typed XlsxWriter calls in constant_memory mode, and start a new sheet, with its own header, before Excel's 1,048,576-row limit. Memory then stays flat whatever the table size, and a final row count per sheet against the database proves the export is complete.

Frequently asked questions

How do I export a large SQL table to Excel without running out of memory? Read it in chunks with pandas read_sql and chunksize, using a server-side cursor, and write each row with XlsxWriter in constant_memory mode so rows are flushed to disk as they are written.

What is the maximum number of rows in an Excel sheet? 1,048,576 rows including the header. Larger exports must be split across several sheets or files.

Why does read_sql with chunksize still use a lot of memory? Many database drivers fetch the whole result into memory when the query runs. Use a connection with stream_results=True so the driver streams rows from a server-side cursor.

Can I use to_excel to write chunks to the same sheet? Not reliably; to_excel writes a whole DataFrame. Write rows yourself with XlsxWriter, keeping track of the next row number.

More on databases, apis & sources

More on databases, apis & sources →