Query Excel Files with SQL Using DuckDB
Some questions about Excel data are easiest to ask in SQL: totals by region joined to a targets table, the top ten products per month, rows in this month's export that were not in last month's. With DuckDB you can ask them directly against the .xlsx files, without loading each one into pandas first and without a database server. This guide, part of Moving Data Between Excel and Databases, uses DuckDB's read_xlsx on report-style workbooks — a title above the table, a stray "n/a" in a number column — and covers the options that make those files behave.
Prerequisites
pip install duckdb pandas openpyxl
The excel extension is downloaded the first time you install it and cached afterwards; on a machine without internet access, install it once on a connected machine and copy the extension directory. Two monthly exports with a title line above the table:
import datetime as dt
import duckdb
import pandas as pd
from openpyxl import Workbook
data = {
"2026-01": [["North", "Tea", 120, dt.date(2026, 1, 5)], ["South", "Tea", 80, dt.date(2026, 1, 9)]],
"2026-02": [["North", "Coffee", 95, dt.date(2026, 2, 2)], ["East", "Tea", "n/a", dt.date(2026, 2, 3)]],
}
for month, rows in data.items():
wb = Workbook()
ws = wb.active
ws.title = "Sales"
ws.append([f"Sales report {month}"])
ws.append([])
ws.append(["region", "product", "revenue", "date"])
for row in rows:
ws.append(row)
wb.save(f"sales_{month}.xlsx")
con = duckdb.connect()
con.sql("INSTALL excel")
con.sql("LOAD excel")
Step 1 — Read the table, not the title
By default read_xlsx takes the first row of the sheet as the header and stops at the first empty row. For a report with a title in A1 and a blank row under it, that returns an empty table with a column called "Sales report 2026-01". Point it at the table instead:
jan = con.sql("""
SELECT * FROM read_xlsx('sales_2026-01.xlsx', sheet = 'Sales',
range = 'A3:D100000', stop_at_empty = true)
""")
print(jan.df())
print(con.sql("DESCRIBE SELECT * FROM jan").df()[["column_name", "column_type"]].values.tolist())
# [['region', 'VARCHAR'], ['product', 'VARCHAR'], ['revenue', 'DOUBLE'], ['date', 'DATE']]
The range starts at the header row. Give it a generous end row and add stop_at_empty = true: without that flag, DuckDB returns every row of the range, blank ones included — ask for A3:D and you get over a million empty rows. Column types are inferred from the cells, so the dates arrive as real DATE values and the revenue as DOUBLE. Saving a relation in a Python variable, as jan here, lets later SQL refer to it by name. Relations are lazy — the file is read again each time a query uses one — so for a workbook you query many times, materialise it once with CREATE TABLE.
Step 2 — Handle text in numeric columns
The February file has "n/a" typed into the revenue column. Type inference picks DOUBLE for the column and then fails on that cell:
try:
con.sql("SELECT * FROM read_xlsx('sales_2026-02.xlsx', range = 'A3:D100000', stop_at_empty = true)").df()
except duckdb.Error as exc:
print(str(exc)[:99])
# Invalid Input Error: read_xlsx: Failed to parse cell 'C5': Could not convert string 'n/a' to DOUBLE
Two options change that. ignore_errors = true keeps the inferred types and turns cells that do not fit into NULL. all_varchar = true reads every cell as text, which never fails — but dates then arrive as Excel serial numbers such as 46055, not as dates:
feb = con.sql("""SELECT * FROM read_xlsx('sales_2026-02.xlsx', range = 'A3:D100000',
stop_at_empty = true, ignore_errors = true)""")
print(feb.df()["revenue"].tolist()) # [95.0, nan]
raw = con.sql("""SELECT revenue, date FROM read_xlsx('sales_2026-02.xlsx', range = 'A3:D100000',
stop_at_empty = true, all_varchar = true)""").df()
print(raw.values.tolist()) # [['95', '46055'], ['n/a', '46056']]
ignore_errors is the practical default for reporting; count the NULLs it produces so the "n/a" does not disappear unnoticed. Use all_varchar when you want to inspect exactly what was typed, and convert columns yourself. Placeholder text in general is covered in Replace Placeholder Text with NaN in pandas.
Step 3 — Combine several workbooks
read_xlsx reads one file per call; it rejects a list of paths, and in our test a wildcard pattern such as 'sales_*.xlsx' returned the rows of a single file rather than all of them. Build one query that unions a read per file, adding the file name as a column:
from pathlib import Path
files = sorted(Path(".").glob("sales_*.xlsx"))
parts = [f"""SELECT *, '{p.name}' AS source FROM read_xlsx('{p.as_posix()}', sheet = 'Sales',
range = 'A3:D100000', stop_at_empty = true, ignore_errors = true)""" for p in files]
sales = con.sql("\nUNION ALL BY NAME\n".join(parts))
print(sales.df()[["region", "revenue", "source"]].values.tolist())
UNION ALL BY NAME matches columns by name rather than position, so files whose columns are in a different order — or where one month has an extra column — still line up, with NULL for columns a file lacks. File names come from your own glob, so building the SQL string is safe here; never paste untrusted text into SQL.
Step 4 — Query and join with plain SQL
With the combined relation named sales, the questions are ordinary SQL — the same SQL you would run against a database table, so analysts who know SQL but not pandas can read and review the logic. DuckDB can also read pandas DataFrames in the same Python scope by their variable name, which is handy for a small targets table kept in code or another sheet:
targets = pd.DataFrame({"region": ["North", "South", "East"], "goal": [200, 100, 50]})
summary = con.sql("""
SELECT s.region,
sum(s.revenue) AS revenue,
any_value(t.goal) AS goal,
count(*) FILTER (WHERE s.revenue IS NULL) AS missing_values
FROM sales AS s
LEFT JOIN targets AS t USING (region)
GROUP BY s.region
ORDER BY s.region
""").df()
print(summary.values.tolist())
The FILTER clause counts the cells ignore_errors turned into NULL, so East's missing revenue is visible in the result instead of silently lowering its total. The same query could join a CSV with read_csv or a Parquet file with read_parquet, which is where DuckDB shines compared with loading everything into pandas: each source is read directly, and only the result becomes a DataFrame.
Step 5 — Write the result back to Excel
The excel extension can also write. COPY … TO with FORMAT xlsx creates a workbook from any query:
con.sql("""
COPY (SELECT region, revenue, goal FROM summary ORDER BY region)
TO 'summary.xlsx' (FORMAT xlsx, HEADER true, SHEET 'Summary')
""")
print(pd.read_excel("summary.xlsx", sheet_name="Summary").values.tolist())
The output is a plain data sheet — no formatting, widths or number formats. For a finished report, fetch the result with .df() and write it with pandas and openpyxl or XlsxWriter, as in Export SQL Query Results to Excel with Python.
Common pitfalls
- Forgetting stop_at_empty with a range. Without it, the empty rows of the range come back as
NULLrows and inflatecount(*). - Reading dates with all_varchar. They become serial-number strings. Keep inferred types for date columns, or convert with
DATE '1899-12-30' + CAST(col AS INTEGER). - Guessing the sheet. Without
sheet, DuckDB reads the first worksheet, which in many exports is a cover page or a pivot. Name the sheet explicitly so a reordered workbook fails loudly instead of returning the wrong table. - Legacy .xls files.
read_xlsxreads only the newer.xlsxformat; convert.xlsfirst, as in Convert XLS to XLSX with Python.
Conclusion
DuckDB turns Excel files into SQL tables. Load the excel extension, read each workbook with read_xlsx using range and stop_at_empty to skip report headers, choose ignore_errors or all_varchar for text in numeric columns, and combine files with UNION ALL BY NAME. Join with pandas DataFrames or other file formats in plain SQL, count the values the reader turned into NULL, and write results back with COPY … (FORMAT xlsx) or through pandas for formatted reports.
Frequently asked questions
Can DuckDB read Excel files? Yes. The excel extension provides read_xlsx, which reads a sheet or a range of an .xlsx file as a table you can query with SQL. Install it once with INSTALL excel and load it with LOAD excel.
How do I skip the title rows above the table? Pass range with the table's top-left corner, such as range='A3:D100000', together with stop_at_empty=true so reading stops at the first blank row instead of returning empty rows up to the end of the range.
Why does read_xlsx fail with "Could not convert string to DOUBLE"? It infers each column's type from the data and a later cell does not fit — typically text such as "n/a" in a numeric column. Pass ignore_errors=true to turn such cells into NULL, or all_varchar=true to read everything as text.
Can read_xlsx read several files at once? It takes one file per call. Build a query that combines one read_xlsx per file with UNION ALL BY NAME, which also lines up columns by name when the files differ in order.
Related
- Up one level: Moving Data Between Excel and Databases — Excel and SQL in both directions.
- Load an Excel File into a SQL Database with pandas — when the data should live in a database.
- Upsert Excel Rows into a Database Table with Python — repeated loads without duplicates.
- Read Large Excel Files in Chunks with pandas — the pandas route for big files.
More on databases, apis & sources
- How-toRead an Excel File Directly from Amazon S3 with pandas
- How-toCache API Responses When Building Excel Reports
- How-toCompare an Excel File with a Database Table
- How-toDownload New Excel Files from an SFTP Server
- How-toExport a Large Database Table to Excel in Chunks
- How-toExport Database Lookup Tables as Excel Dropdown Lists