Query Large CSV Files with DuckDB
Answering "revenue by region for September, excluding cancelled orders" from a 9 GB CSV means writing a chunk loop: read 500,000 rows, filter, group, keep partial results, combine at the end, and hope the combination logic is right for every aggregation. Each new question means another loop. Loading the file once into pandas is not an option — it needs more memory than the machine has — and moving it into a database means provisioning one, defining a schema and maintaining a load job for a file that is regenerated every night anyway.
Root Cause
pandas is an in-memory engine: it must materialise what it reads, so a file larger than memory forces manual chunking, and every chunked aggregation reimplements a piece of a query planner. The chunk loop is not just tedious — it is where correctness errors live, because some aggregations do not combine by simple addition (averages, medians, distinct counts). What the task actually needs is a query engine that streams data from disk, applies projections and filters while reading, and handles the combination of partial aggregates itself. DuckDB provides exactly that in-process: it reads CSV, Parquet and JSON directly from the file system, pushes filters and column selection into the scan, spills to disk when a query exceeds memory, and returns results as pandas DataFrames or Arrow tables.
Minimal Diagnostic
Measure the two approaches on a real question before rewriting anything: peak memory and wall time for the chunked pandas loop versus the same query in DuckDB.
# pip install duckdb "pandas>=2.2"
import time
import tracemalloc
from pathlib import Path
import duckdb
import pandas as pd
SOURCE = Path("in/transactions-2026.csv")
def pandas_chunks() -> pd.DataFrame:
parts = []
for chunk in pd.read_csv(SOURCE, chunksize=500_000, usecols=["region", "status", "booked_at", "amount"],
parse_dates=["booked_at"]):
chunk = chunk[(chunk["status"] != "cancelled") & (chunk["booked_at"].dt.month == 9)]
parts.append(chunk.groupby("region", observed=True)["amount"].sum())
return pd.concat(parts).groupby(level=0).sum().rename("revenue").reset_index()
def duckdb_query() -> pd.DataFrame:
return duckdb.sql(f"""
SELECT region, sum(amount) AS revenue
FROM read_csv_auto('{SOURCE}')
WHERE status <> 'cancelled' AND month(booked_at) = 9
GROUP BY region ORDER BY revenue DESC
""").df()
for name, fn in (("pandas chunks", pandas_chunks), ("duckdb", duckdb_query)):
tracemalloc.start()
start = time.perf_counter()
result = fn()
peak = tracemalloc.get_traced_memory()[1] / 1e6
tracemalloc.stop()
print(f"{name:<14} {time.perf_counter() - start:6.1f}s peak python memory {peak:7.1f} MB rows {len(result)}")
pandas chunks 186.4s peak python memory 1180.3 MB rows 5
duckdb 21.7s peak python memory 1.9 MB rows 5
DuckDB reads the same file roughly eight times faster and returns a five-row result without materialising anything in Python. The memory figure is only Python's allocations — DuckDB manages its own buffer pool, which respects a configurable limit.
Fix: Query the Files Directly, with Types Declared
Point DuckDB at the file, declare the column types that matter, set a memory limit, and hand the small result to pandas. Changed lines carry comments.
# pip install duckdb "pandas>=2.2"
from pathlib import Path
import duckdb
import pandas as pd
SOURCE = Path("in/transactions-2026.csv")
def connect(memory_limit: str = "4GB", threads: int = 4) -> duckdb.DuckDBPyConnection:
con = duckdb.connect() # in-process, no server
con.execute(f"SET memory_limit='{memory_limit}'") # changed: bound memory, spill to disk
con.execute(f"SET threads={threads}")
con.execute("SET preserve_insertion_order=false") # changed: lets large scans stream freely
return con
def revenue_by_region(con: duckdb.DuckDBPyConnection, path: Path, month: int) -> pd.DataFrame:
return con.execute(
"""
SELECT region,
count(*) AS orders,
sum(amount) AS revenue,
count(DISTINCT customer_ref) AS customers -- correct across the whole file
FROM read_csv(?, header=true, types={'customer_ref': 'VARCHAR', 'amount': 'DOUBLE'},
timestampformat='%Y-%m-%d %H:%M:%S') -- changed: declared types, no guessing
WHERE status <> 'cancelled' AND month(booked_at) = ?
GROUP BY region
ORDER BY revenue DESC
""",
[str(path), month], # changed: parameters, not string formatting
).df() # changed: small result into pandas
if __name__ == "__main__":
with connect() as con:
print(revenue_by_region(con, SOURCE, 9).to_string(index=False))
region orders revenue customers
EMEA 812043 18904221.55 44120
North America 611209 14220118.02 31877
APAC 402118 9014772.10 20441
count(DISTINCT customer_ref) is the aggregation that chunk loops get wrong most often — distinct counts cannot be summed across chunks — and DuckDB computes it correctly across the whole file without extra code. Declaring customer_ref as VARCHAR prevents the sampling-based type detection from deciding it is an integer because the first rows look numeric. Parameter binding keeps file paths and values out of the SQL string, which matters as soon as any part of the query comes from configuration.
Variant Fix 1: A Folder of Files, and Joins Between Them
DuckDB globs paths, so a folder of monthly exports is one table, and files of different formats join directly:
# pip install duckdb
import duckdb
with duckdb.connect() as con:
con.execute("SET memory_limit='4GB'")
result = con.execute("""
WITH tx AS (
SELECT * FROM read_csv('in/exports/transactions-*.csv',
union_by_name=true, -- tolerate column order changes
filename=true) -- keep the source file name
),
customers AS (
SELECT * FROM read_parquet('reference/customers.parquet')
)
SELECT c.segment,
date_trunc('month', tx.booked_at) AS month,
sum(tx.amount) AS revenue
FROM tx
JOIN customers c ON c.customer_ref = tx.customer_ref
WHERE tx.status <> 'cancelled'
GROUP BY ALL
ORDER BY month, revenue DESC
""").df()
print(result.head())
union_by_name=true matches columns by header rather than position, which keeps a supplier's reordered export from silently shifting data. filename=true adds the source path as a column — invaluable when one file in a folder turns out to be malformed. Joining a CSV against a Parquet reference table needs no import step for either side.
Variant Fix 2: Results Too Large for Memory
When the result itself is large — a cleaned, filtered extract rather than a summary — write it straight to Parquet or CSV without passing through Python:
# pip install duckdb
import duckdb
with duckdb.connect() as con:
con.execute("SET memory_limit='4GB'")
con.execute("""
COPY (
SELECT customer_ref, booked_at, amount, region
FROM read_csv('in/transactions-2026.csv',
types={'customer_ref': 'VARCHAR'})
WHERE status <> 'cancelled'
) TO 'out/clean-transactions.parquet' (FORMAT parquet, COMPRESSION zstd)
""")
For iterative work, con.execute(sql).fetch_record_batch() streams Arrow batches into Python so a large result can be processed incrementally. Persisting intermediate results into a DuckDB database file (duckdb.connect("analysis.duckdb")) also avoids re-scanning the CSV for every question during exploration — and once the data lives in Parquet, subsequent queries are faster still, as covered in convert a large CSV to Parquet with Python.
Using DuckDB Alongside pandas
DuckDB reads pandas DataFrames as tables by name, so existing pandas code can stay where it is useful and hand heavy steps to SQL:
# pip install duckdb "pandas>=2.2"
import duckdb
import pandas as pd
regions = pd.DataFrame({"region": ["EMEA", "APAC"], "manager": ["J. Patel", "M. Chen"]})
with duckdb.connect() as con:
summary = con.execute("""
SELECT r.manager, sum(t.amount) AS revenue
FROM read_csv('in/transactions-2026.csv', types={'customer_ref': 'VARCHAR'}) t
JOIN regions r ON r.region = t.region -- the pandas DataFrame, referenced by variable name
WHERE t.status <> 'cancelled'
GROUP BY r.manager
""").df()
print(summary)
The DataFrame is read in place, without a copy, which makes DuckDB a practical way to join a small reference table held in pandas against a file too large to load. Keep the split clear: SQL for the heavy scan, filter, join and aggregate; pandas for the small result, formatting and export, as in Building Pivot Tables and Summaries for Excel.
Verification
Check DuckDB's answers against a pandas computation on a sample small enough to load, and assert that row counts and totals reconcile with the file.
# pip install duckdb "pandas>=2.2"
from pathlib import Path
import duckdb
import pandas as pd
def verify_against_pandas(path: Path, sample_rows: int = 200_000) -> None:
sample = pd.read_csv(path, nrows=sample_rows, dtype={"customer_ref": "string"},
parse_dates=["booked_at"])
expected = (sample[(sample["status"] != "cancelled") & (sample["booked_at"].dt.month == 9)]
.groupby("region", observed=True)["amount"].sum().sort_index())
with duckdb.connect() as con:
actual = con.execute("""
SELECT region, sum(amount) AS revenue
FROM (SELECT * FROM read_csv(?, types={'customer_ref': 'VARCHAR'}) LIMIT ?)
WHERE status <> 'cancelled' AND month(booked_at) = 9
GROUP BY region ORDER BY region
""", [str(path), sample_rows]).df().set_index("region")["revenue"]
pd.testing.assert_series_equal(expected, actual, check_names=False, rtol=1e-9)
with duckdb.connect() as con:
rows = con.execute("SELECT count(*) FROM read_csv(?)", [str(path)]).fetchone()[0]
with path.open("rb") as fh:
line_count = sum(chunk.count(b"\n") for chunk in iter(lambda: fh.read(1 << 24), b""))
assert abs(rows - (line_count - 1)) <= 1, f"DuckDB counted {rows} rows, file has ~{line_count - 1} lines"
print(f"verified: {rows:,} rows, sample aggregation matches pandas")
The line-count check catches the case where a quoting or delimiter misconfiguration makes DuckDB see a different number of rows than the file contains — the same class of problem as a CSV dialect mismatch on export. Values containing embedded newlines make the counts differ legitimately, so treat a small difference as a prompt to inspect rather than an automatic failure.
FAQ
Does DuckDB need a server or a schema? No. It runs in the Python process and reads files directly; a persistent database file is optional.
Can it read compressed CSVs?
Yes — .gz, .zst and .bz2 are detected automatically, including inside globs.
What if type detection gets a column wrong?
Pass types={...} for those columns, or all_varchar=true to read everything as text and cast explicitly in SQL.
Is the result exactly what pandas would produce? Floating-point sums can differ in the last bits because of different summation order; compare with a tolerance rather than exact equality.
Related
- Handling Large CSV Files with Chunking — when chunking in pandas is still the right tool
- Fix Wrong Totals When Aggregating CSV Chunks — the combination errors DuckDB avoids
- Convert a Large CSV to Parquet with Python — making repeated queries faster
- Comparing and Reconciling Spreadsheets — joins at a scale where SQL helps