Guide
Advanced Data Transformation And CleaningDeep dive

Export SQL Query Results to Excel with Python

Run a parameterised query with SQLAlchemy and write the result to a formatted workbook: one sheet per result set, correct date and money formats, sized columns, and chunked reads for big results.

An export from a database to a spreadsheet is the most common single job in reporting, and the two-line version — pd.read_sql(sql, engine).to_excel("out.xlsx") — is genuinely fine for a one-off. What makes it worth writing carefully is that it usually turns into a scheduled job that someone opens every Monday: at that point the date column showing 45839, the amounts running to fifteen decimal places and the columns too narrow to read all become recurring complaints.

This guide, part of Moving Data Between Excel and Databases, builds the version worth scheduling: parameterised SQL, several result sets in one workbook, formats applied where they carry meaning, and a chunked path for results too large to hold in memory.

From a parameterised query to a formatted workbook A query with bound parameters runs against the engine and returns a DataFrame. One ExcelWriter session writes a summary sheet and a detail sheet, then a formatting pass sets the date and currency formats, column widths and a frozen header row before the file is saved. One connection, one writer session, one formatting pass text() query :start and :end bound read_sql DataFrame, typed ExcelWriter Summary + Detail format pass dates · money · widths The formatting pass is not decoration Dates as serial numbers and amounts at fifteen decimal places turn a correct export into a support ticket.

Prerequisites

Bash
pip install pandas openpyxl sqlalchemy
# plus one driver:  psycopg[binary] | pymysql | pyodbc

The examples use SQLite so they run anywhere with no server, and the only line that changes for Postgres, MySQL or SQL Server is the URL passed to create_engine.

Step 1: Build a sample database

So the rest of the guide runs end to end:

Python
import pandas as pd
from sqlalchemy import create_engine, text

engine = create_engine("sqlite:///sales.db")

seed = pd.DataFrame({
    "order_id": range(1, 13),
    "region": ["North", "South", "East"] * 4,
    "order_date": pd.to_datetime(
        ["2026-07-%02d" % d for d in (1, 3, 5, 8, 11, 14, 17, 19, 22, 25, 28, 30)]),
    "amount": [120.5, 340.0, 75.25, 410.75, 88.0, 260.4,
               190.9, 305.6, 141.35, 520.0, 96.8, 233.2],
})
seed.to_sql("orders", engine, if_exists="replace", index=False)

Step 2: Query with bound parameters

Never interpolate values into the SQL string. text() with named placeholders is both safer and faster, because the database can reuse the plan:

Python
DETAIL = text("""
    SELECT order_id, region, order_date, amount
    FROM orders
    WHERE order_date >= :start AND order_date < :end
    ORDER BY order_date, region
""")

params = {"start": "2026-07-01", "end": "2026-08-01"}

with engine.connect() as conn:
    detail = pd.read_sql(DETAIL, conn, params=params, parse_dates=["order_date"])

summary = (detail.groupby("region", as_index=False)["amount"]
                 .agg(orders="count", amount="sum")
                 .sort_values("amount", ascending=False))
A bound parameter and an interpolated string take different routes With bound parameters the statement and the values travel separately: the database parses the statement once and treats every value as data, so a value containing SQL is harmless. With an f-string the value is pasted into the statement text before the database sees it, so its contents become part of the query. text() with :start and :end statement values database — parses once, binds values a value is only ever data the plan is cached and reused across runs f"... WHERE d >= '{value}'" statement values pasted in first database — one string, parsed as written a value can change the statement and a new plan is compiled for every run

parse_dates matters more than it looks. SQLite has no date type and returns strings; several drivers return decimal.Decimal for numeric columns, which pandas keeps as object. Being explicit at the read is what stops a date column arriving in Excel as text that cannot be sorted.

Where the aggregation is expensive, push it into SQL instead of doing it in pandas — the database can group ten million rows without transferring them to your process, and the DataFrame you receive is the small one.

Step 3: Write both result sets into one workbook

One ExcelWriter session, one file, several sheets. Opening to_excel twice on the same path writes the file twice and keeps only the last:

Python
with pd.ExcelWriter("regional-july.xlsx", engine="openpyxl",
                    datetime_format="yyyy-mm-dd") as writer:
    summary.to_excel(writer, sheet_name="Summary", index=False)
    detail.to_excel(writer, sheet_name="Detail", index=False)

index=False keeps pandas' row numbers out of column A, where they otherwise appear as an unnamed column and shift every heading right. datetime_format on the writer applies to every datetime column in every sheet, which is the cheapest way to stop dates rendering as five-digit serial numbers.

Step 4: Format the sheets so people can read them

The formatting pass reopens the sheets through the same writer session and applies what the data means:

Python
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter

HEADER_FILL = PatternFill("solid", start_color="1F4E78")
HEADER_FONT = Font(bold=True, color="FFFFFF")


def polish(ws, money_cols=(), date_cols=(), min_width=10, max_width=44):
    for cell in ws[1]:
        cell.fill, cell.font = HEADER_FILL, HEADER_FONT
        cell.alignment = Alignment(horizontal="center")

    for col in money_cols:
        for cell in ws[col][1:]:
            cell.number_format = '#,##0.00'
    for col in date_cols:
        for cell in ws[col][1:]:
            cell.number_format = 'yyyy-mm-dd'

    for idx, column in enumerate(ws.columns, start=1):
        longest = max((len(str(c.value)) for c in column if c.value is not None),
                      default=0)
        letter = get_column_letter(idx)
        ws.column_dimensions[letter].width = min(max(longest + 2, min_width),
                                                 max_width)
    ws.freeze_panes = "A2"
    ws.auto_filter.ref = ws.dimensions


with pd.ExcelWriter("regional-july.xlsx", engine="openpyxl",
                    datetime_format="yyyy-mm-dd") as writer:
    summary.to_excel(writer, sheet_name="Summary", index=False)
    detail.to_excel(writer, sheet_name="Detail", index=False)
    polish(writer.sheets["Summary"], money_cols=("C",))
    polish(writer.sheets["Detail"], money_cols=("D",), date_cols=("C",))

writer.sheets gives you the openpyxl worksheet for a sheet pandas has already written, so the data write and the styling happen in one pass and one save. There is no autofit in the file format — the width calculation above measures the longest string in the column, which is why the cap exists: one long free-text comment would otherwise push a column off the screen. The techniques here are covered further in Styling Excel Cells with openpyxl and Applying Number and Date Formats in Excel.

Step 5: Handle a result set too large to hold

Past a few hundred thousand rows, reading the whole result into a DataFrame before writing anything is what runs the job out of memory. chunksize turns read_sql into an iterator, and openpyxl's write-only mode streams rows straight to the file:

Python
from openpyxl import Workbook

wb = Workbook(write_only=True)
ws = wb.create_sheet("Detail")

first = True
rows_written = 0
with engine.connect().execution_options(stream_results=True) as conn:
    for chunk in pd.read_sql(DETAIL, conn, params=params, chunksize=50_000):
        if first:
            ws.append(list(chunk.columns))
            first = False
        for row in chunk.itertuples(index=False, name=None):
            ws.append(row)
        rows_written += len(chunk)

wb.save("detail-large.xlsx")
print(f"wrote {rows_written:,} rows")

stream_results=True tells the driver to fetch server-side rather than buffering the whole result client-side, which is the half people forget — without it, chunksize limits how much pandas materialises but not how much the driver already pulled. Write-only mode cannot revisit a cell, so any formatting has to be applied as each row is appended. Write Large DataFrames to Excel with Write-Only Mode covers that trade-off.

Reading a whole result set versus streaming it in chunks Without chunking, the driver buffers the entire result, pandas materialises it as one DataFrame and openpyxl builds every cell in memory, so peak memory is roughly three copies of the data. With server-side streaming, a chunk size and write-only mode, only one chunk exists at a time and memory stays flat regardless of how many rows are exported. read it all driver buffer — every row one DataFrame — every row again openpyxl cells — a third copy peak memory grows with the export two million rows is where jobs start dying stream in chunks chunk 1 chunk 2 chunk 3 write-only sheet rows appended, then freed peak memory stays flat one chunk in flight, whatever the row count

Common pitfalls and gotchas

SymptomCauseFix
UserWarning: pandas only supports SQLAlchemy connectableA raw DBAPI connection was passedWrap it with create_engine
Dates show as 45839The column reached Excel as a numberparse_dates at the read, datetime_format on the writer
An unnamed first column of row numbersindex=True (the default)Pass index=False
Only the last sheet survivesto_excel called on the path repeatedlyOne ExcelWriter context, several to_excel calls
Amounts show 15 decimal placesDriver returned floats with no format appliedSet number_format on the column
The job hangs on a large queryClient-side bufferingstream_results=True plus chunksize
Password visible in a tracebackURL built inline in the callRead it from an environment variable
Report is stale but nobody noticedNo generated-at stampWrite the timestamp into the summary sheet

Performance and scale notes

The three costs are the query, the transfer and the write, and they respond to different fixes. A slow query is a database problem — index the filter columns and aggregate server-side. A slow transfer is usually too many columns: SELECT * on a wide table moves fields nobody will read. A slow write is openpyxl building cell objects, which write-only mode avoids.

As a rough guide, a formatted 50,000-row export takes a few seconds and a couple of hundred megabytes; 500,000 rows through the streaming path stays flat on memory but produces a file that takes Excel a noticeable time to open. Past that, ask whether the detail sheet is being read at all — a summary workbook plus a CSV for the detail is nearly always the better delivery. Convert Excel to CSV with Python covers that split.

Conclusion

Query with bound parameters, read with explicit types, write every result set through one ExcelWriter session, and finish with a formatting pass that fixes dates, money and column widths. Where the result is large, stream it: stream_results=True, a chunksize, and openpyxl's write-only mode keep memory flat regardless of row count. That is the difference between an export that works once and one that can be scheduled and forgotten.

Frequently asked questions

Why does pandas warn about a DBAPI2 connection? pandas only supports SQLAlchemy connectables and SQLite connections officially. Passing a raw psycopg or pyodbc connection still works for reading but emits a UserWarning; wrap it in create_engine and the warning goes away.

How do I export several queries into one workbook? Open a single pd.ExcelWriter as a context manager and call to_excel once per DataFrame with a different sheet_name. Opening the file repeatedly overwrites it each time.

My dates arrive as 45839 in Excel — why? The column reached Excel as a number rather than a datetime. Set datetime_format on the ExcelWriter, and make sure the column's dtype is datetime64 before writing rather than object.

The export takes minutes and eats memory. What should I change? Read in chunks with chunksize and write with a streaming engine, and push the aggregation into SQL. A GROUP BY in the database is almost always faster than the same operation over a DataFrame you had to transfer first.

Up to the parent guide:

Related guides: