Export SQL Query Results to Excel with Python
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.
Prerequisites
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:
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:
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))
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:
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:
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:
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.
Common pitfalls and gotchas
| Symptom | Cause | Fix |
|---|---|---|
UserWarning: pandas only supports SQLAlchemy connectable | A raw DBAPI connection was passed | Wrap it with create_engine |
Dates show as 45839 | The column reached Excel as a number | parse_dates at the read, datetime_format on the writer |
| An unnamed first column of row numbers | index=True (the default) | Pass index=False |
| Only the last sheet survives | to_excel called on the path repeatedly | One ExcelWriter context, several to_excel calls |
| Amounts show 15 decimal places | Driver returned floats with no format applied | Set number_format on the column |
| The job hangs on a large query | Client-side buffering | stream_results=True plus chunksize |
| Password visible in a traceback | URL built inline in the call | Read it from an environment variable |
| Report is stale but nobody noticed | No generated-at stamp | Write 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.
Related
Up to the parent guide:
- Moving Data Between Excel and Databases — connections, type mapping and the return leg.
Related guides:
- Load an Excel File into a SQL Database with pandas — the same boundary in the other direction.
- Refresh an Excel Report from a Database on a Schedule — running this export unattended.
- Write Multiple DataFrames to One Excel File — the multi-sheet writer pattern in depth.
- Read Large Excel Files in Chunks with pandas — the same chunking idea applied to reading spreadsheets.