DuckDB: In-process OLAP SQL, Parquet and Python Reference Guide
By DevShelfHub
In-process OLAP SQL — query Parquet, CSV, and JSON in place without loading into a server. Covers LIST/STRUCT/MAP types, QUALIFY, ASOF JOIN, PIVOT, window functions, the Python API, and Arrow/DataFrame interop for duckdb 1.1+.
DuckDB is an in-process columnar SQL engine — like SQLite for analytics.
Released 1.0 in mid-2024; the project ships features fast (QUALIFY,
ASOF JOIN, PIVOT/UNPIVOT,
typed secrets, attach to Postgres/MySQL/SQLite).
The on-disk file format is stable across 1.x.
Install · connectSetup
bash
# macOS / Linux — Homebrew
brew install duckdb
# Or grab a static binary (Linux/macOS/Windows)
curl -L https://install.duckdb.org | sh
# Python — pip
pip install "duckdb>=1.1"
# Open in-memory shell
duckdb
# Open or create a persistent DB file
duckdb sales.duckdb
# One-shot — emit CSV from a Parquet file in one line
duckdb -c "COPY (SELECT * FROM 's3://bucket/y=2026/*.parquet') TO 'out.csv'"
# Python check
python -c "import duckdb; print(duckdb.__version__)"
-- Query a remote Parquet file in place — no copy, no load.
-- DuckDB pushes filters + column projections down into the Parquet reader.
INSTALL httpfs; LOAD httpfs;
-- (Optional) S3 credentials via the typed secret API
CREATE OR REPLACE SECRET s3_demo (
TYPE s3,
KEY_ID 'AKIA...',
SECRET '****',
REGION 'us-east-1'
);
-- Glob across a Hive-partitioned dataset; only the year=2026 files are read.
SELECT region,
sum(amount) AS revenue,
count(*) AS orders
FROM read_parquet(
's3://acme-data/orders/year=*/month=*/*.parquet',
hive_partitioning = TRUE
)
WHERE year = 2026
AND status = 'paid'
GROUP BY region
ORDER BY revenue DESC;
-- Materialize a slice locally for further work
CREATE TABLE orders_q1 AS
SELECT * FROM read_parquet('s3://acme-data/orders/year=2026/month=0[1-3]/*.parquet');
Primitives · compositesData types
TINYINT · SMALLINT · INTEGER · BIGINT · HUGEINT
Signed ints (1, 2, 4, 8, 16 bytes).
UTINYINT · USMALLINT · UINTEGER · UBIGINT
Unsigned counterparts.
DECIMAL(18, 4)
Fixed precision. Use for money.
FLOAT · DOUBLE
IEEE-754. Avoid for currency.
VARCHAR
UTF-8 string — no length limit; VARCHAR(n) is hint-only.
DATE · TIME · TIMESTAMP · TIMESTAMPTZ · INTERVAL
Temporal types. Microsecond precision.
LIST(INTEGER) e.g. [1, 2, 3]
Homogeneous array, variable length.
STRUCT(a INT, b VARCHAR) e.g. {'a':1,'b':'x'}
Named-field record. Fields are addressable.
MAP(VARCHAR, INTEGER) e.g. MAP{'a':1}
Key-value pairs of one type each.
UNION(num INT, text VARCHAR)
Tagged union — one of multiple types per row.
CREATE TYPE size AS ENUM('s','m','l')
Bounded set; stored as tiny int.
Tables · attach · copyDDL & DML
CREATE TABLE t (id INT PRIMARY KEY, ...)
Standard. PK enforced.
CREATE OR REPLACE TABLE t AS SELECT ...
Idempotent rebuild — nice in notebooks.
CREATE TEMP TABLE t (...)
Session-scoped; gone on disconnect.
CREATE VIEW v AS SELECT ...
Saved query; recomputed on each call.
INSERT INTO t SELECT * FROM 'src.parquet'
Bulk load via the file reader.
COPY t TO 'out.parquet' (FORMAT 'parquet', COMPRESSION 'zstd')
Single-file export.
COPY (SELECT ...) TO 'out.parquet'
Export an ad-hoc query.
COPY t TO 'dir' (FORMAT PARQUET, PARTITION_BY (year, month))
Hive-partition the output by columns.
EXPORT DATABASE 'dump_dir'
Schema + Parquet data for every table.
IMPORT DATABASE 'dump_dir'
Restore from EXPORT DATABASE.
ATTACH 'other.duckdb' AS other
Mount a second DuckDB file.
ATTACH 'postgres:dbname=app' AS pg (TYPE POSTGRES)
Live-read Postgres in queries (postgres_scanner).
Friendly SQL extrasQueries
SELECT * EXCLUDE (password) FROM users
Drop columns from * without listing the rest.
SELECT * REPLACE (lower(email) AS email) FROM users
Rewrite one column in-place.
SELECT * RENAME (created AS created_at) FROM t
Rename on read; no DDL needed.
SELECT COLUMNS('^amt_') FROM sales
Regex column picker — sums for all amt_*.
SELECT * FROM t USING SAMPLE 10%
Statistically uniform sample.
SELECT * FROM t USING SAMPLE 1000 ROWS (reservoir)
Fixed-size sample — great for dashboards.
SELECT * QUALIFY row_number() OVER (PARTITION BY u ORDER BY ts DESC) = 1
Filter on a window result without a subquery.
SELECT u.*, p.* FROM users u, LATERAL (SELECT * FROM posts WHERE user_id=u.id LIMIT 3) p
Top-N per group via LATERAL.
PIVOT sales ON region USING sum(amount)
Wide-table pivot in one statement.
UNPIVOT wide ON jan, feb, mar INTO NAME month VALUE sales
Long-format the opposite direction.
SELECT o.* FROM orders o ASOF JOIN prices p ON o.sym=p.sym AND o.ts >= p.ts
As-of join — pick the latest price ≤ order timestamp.
OVER framesWindow functions
row_number() OVER (PARTITION BY u ORDER BY ts)
Sequential index per group.
rank() · dense_rank() · ntile(4)
Ranking + quartile bucketing.
lag(col, 1) · lead(col, 1)
Prev / next row in the partition.
sum(x) OVER (PARTITION BY u ORDER BY d ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
Trailing 7-day total.
avg(x) OVER (ORDER BY ts RANGE BETWEEN INTERVAL 1 HOUR PRECEDING AND CURRENT ROW)
Time-range frame.
first_value(x) OVER (... GROUPS BETWEEN 1 PRECEDING AND CURRENT ROW)
Group-based frame (3 frame modes: ROWS / RANGE / GROUPS).
... ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW
Frame exclusion clause.
WINDOW w AS (PARTITION BY u ORDER BY ts) ... sum(x) OVER w
Named window — reuse the spec across columns.
sql
-- 7-day trailing revenue per user, with day-over-day delta and quartile bucket.
WITH daily AS (
SELECT user_id,
date_trunc('day', ts) AS d,
sum(amount) AS rev
FROM orders
WHERE status = 'paid'
GROUP BY user_id, d
)
SELECT user_id, d, rev,
-- trailing 7 days, including today (sliding window, rows-based)
sum(rev) OVER w7 AS rev_7d,
-- yesterday's revenue, for delta plots
lag(rev, 1) OVER (PARTITION BY user_id ORDER BY d) AS rev_prev,
-- quartile bucket within each day
ntile(4) OVER (PARTITION BY d ORDER BY rev) AS rev_q
FROM daily
WINDOW w7 AS (
PARTITION BY user_id ORDER BY d
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
)
QUALIFY rev_q = 4 -- keep top-quartile rows only
ORDER BY d DESC, rev_7d DESC;
GROUP BY · FILTERAggregations
GROUP BY GROUPING SETS ((), (u), (u, region))
Multiple groupings in one pass.
GROUP BY ROLLUP (region, city)
Hierarchical totals + subtotals.
GROUP BY CUBE (a, b)
All combinations of grouping cols.
count(*) FILTER (WHERE status='ok')
Per-aggregate conditional. Cleaner than CASE WHEN.
array_agg(col ORDER BY ts) · list(col)
Collect into a LIST. list() is the DuckDB alias.
string_agg(name, ', ' ORDER BY name)
Concatenate.
approx_count_distinct(col)
HyperLogLog. Fast, ~2% error.
quantile_cont(col, [0.5, 0.95, 0.99])
Multiple quantiles in one expression.
histogram(col, 10)
Equal-width bins; returns a LIST(STRUCT).
duckdb packagePython integration
import duckdb; con = duckdb.connect()
In-memory connection.
duckdb.connect("file.duckdb")
Persistent.
duckdb.sql("SELECT 1")
Module-level shortcut on the default in-memory con.
con.execute("SELECT * FROM t WHERE id = ?", [42]).fetchone()
Parameterized. ? placeholders.
con.sql("SELECT * FROM df_var")
Reference a Python DataFrame by its variable name.
import duckdb
import pandas as pd
# In-memory connection (or duckdb.connect("file.duckdb") for persistence)
con = duckdb.connect()
# Pandas → DuckDB: query a DataFrame by its Python variable name
sales = pd.DataFrame({"region": ["EU", "US", "EU"], "amount": [10, 20, 5]})
top = con.sql("""
SELECT region, sum(amount) AS total
FROM sales
GROUP BY region
ORDER BY total DESC
""")
print(top.df()) # Result -> DataFrame
print(top.arrow()) # Result -> pyarrow.Table (zero-copy)
# Parameterized — bind values, never f-string
row = con.execute(
"SELECT total FROM (SELECT region, sum(amount) AS total FROM sales GROUP BY region) "
"WHERE region = ?", ["EU"]
).fetchone()
print(row) # (15,)
# Register a fresh DataFrame under a chosen name
con.register("orders_df", pd.read_csv("orders.csv"))
con.sql("SELECT count(*) FROM orders_df").show()
# Export query result straight to Parquet — no intermediate file
con.sql("SELECT * FROM sales WHERE amount > 5").write_parquet("hot.parquet")
Lazy-loadedExtensions
INSTALL httpfs; LOAD httpfs;
HTTPS / S3 / GCS / Azure Blob.
INSTALL parquet; LOAD parquet;
Bundled but lazy. Auto-loaded when you read a .parquet path.
INSTALL spatial; LOAD spatial;
GIS types, GeoParquet, Shapefile, Excel via GDAL.
INSTALL fts; LOAD fts;
Inverted-index full-text search; BM25 ranking.
INSTALL json; LOAD json;
JSON read + path functions.
INSTALL postgres_scanner; LOAD postgres_scanner;
ATTACH a live Postgres database.
INSTALL sqlite_scanner; LOAD sqlite_scanner;
ATTACH a SQLite file as a foreign DB.
SELECT * FROM duckdb_extensions()
List installed / loaded extensions.
EXPLAIN · pragmasPerformance
PRAGMA threads = 8
Worker count. Default = physical cores.
PRAGMA memory_limit = '8GB'
Soft cap. Engine spills to temp_directory over this.
PRAGMA temp_directory = '/tmp/duck'
Where to spill larger-than-RAM queries.
EXPLAIN SELECT ...
Plan tree.
EXPLAIN ANALYZE SELECT ...
Plan + per-op timings + row counts.
PRAGMA enable_profiling = 'json'; SET profile_output='plan.json'
Persist profile to a file.
SET preserve_insertion_order = false
Faster scans; final ordering undefined unless you ORDER BY.
CHECKPOINT
Flush WAL into the main file. Safe before backup.
Full pipeline · ~25 linesEnd-to-end · Parquet analytics
Reads a Hive-partitioned dataset on S3, ranks the top-5 regions per month, and writes
the result back out as a single Parquet file. Replace the S3 path and you have a working
analytics job.
python
# End-to-end: read remote Parquet, aggregate, write Parquet out.
# Replaces a small PySpark/Pandas script — runs single-process, multi-core.
import duckdb
con = duckdb.connect() # in-memory, multi-threaded
con.execute("INSTALL httpfs; LOAD httpfs;")
con.execute("PRAGMA threads = 8")
con.execute("PRAGMA memory_limit = '4GB'")
# Hive-partitioned dataset on S3 → ranked top regions per month, in one query
result = con.sql("""
WITH paid AS (
SELECT region, year, month, amount
FROM read_parquet(
's3://acme-data/orders/year=*/month=*/*.parquet',
hive_partitioning = TRUE
)
WHERE status = 'paid'
)
SELECT year, month, region, revenue
FROM (
SELECT year, month, region,
sum(amount) AS revenue,
row_number() OVER (PARTITION BY year, month
ORDER BY sum(amount) DESC) AS rk
FROM paid
GROUP BY year, month, region
)
WHERE rk <= 5
ORDER BY year DESC, month DESC, rk
""")
result.write_parquet("top_regions_by_month.parquet") # columnar, compressed
print(result.df().head()) # peek as a DataFrame
Best practiceGood to know
Read remote Parquet directly — don’t download first.
With httpfs loaded, predicate + column pushdown means a
WHERE year=2026 on a Hive-partitioned bucket only fetches the matching files.
Often beats Spark for one-machine workloads up to ~100 GB.
Use QUALIFY instead of a subquery filter.SELECT * QUALIFY row_number() OVER (...) = 1 is the cleanest top-N-per-group
pattern you’ll find in SQL.
Round-trip with Arrow when leaving Python.rel.arrow() is zero-copy into pyarrow; from there, hand-off to Polars / DataFusion /
a network sink without re-materializing.
Common trapsWatch out for
DuckDB is single-process.
One writer at a time per .duckdb file. For shared writes you need a server
(DuckDB Cloud, MotherDuck) or a Postgres in front. It is not a replacement for OLTP.
Auto-detect can guess wrong on messy CSVs.
Mixed-typed columns become VARCHAR silently. Pass an explicit
columns={'id':'INTEGER',...} for production loads.
Insertion order is preserved by default — at a cost.SET preserve_insertion_order = false can double scan speed on wide
analytical queries. Just remember to ORDER BY when order actually matters.
What is DuckDB and how is it different from SQLite?
DuckDB is an in-process columnar SQL engine optimised for analytical (OLAP) queries — aggregations, window functions, and scans across millions of rows. SQLite is row-oriented and built for transactional (OLTP) workloads. DuckDB is like SQLite for data analysis.
Can DuckDB query Parquet and CSV files directly?
Yes. DuckDB can query Parquet, CSV, JSON, Arrow, and even remote S3 files without importing them first. Use SELECT * FROM read_parquet('s3://bucket/data/*.parquet') or simply FROM 'file.csv'. The scanner infers schema automatically.
Is DuckDB suitable for production use?
DuckDB is production-ready for single-node analytical workloads — ETL pipelines, data exploration, and read-heavy dashboards. It is not a replacement for OLTP databases or distributed systems like Spark. For cloud-scale or multi-writer scenarios, consider MotherDuck or Parquet on S3.
How do I use DuckDB with Python pandas or polars?
Create a connection with duckdb.connect() then use con.sql("SELECT * FROM df") to query a pandas or polars DataFrame directly by name — no import step needed. Fetch results with .df() for pandas, .pl() for polars, or .arrow() for PyArrow.
How does DuckDB compare to BigQuery or Snowflake?
DuckDB runs in-process on your machine or server with no infrastructure cost. BigQuery and Snowflake are managed cloud data warehouses that scale across distributed storage but charge per query. DuckDB is faster for local data and free; cloud warehouses shine for terabyte-scale shared datasets.
Does DuckDB support window functions and advanced SQL?
Yes. DuckDB supports the full window function syntax, QUALIFY (filtering window results inline), ASOF JOIN for time-series alignment, PIVOT/UNPIVOT, LIST/STRUCT/MAP types, and recursive CTEs. It closely follows the SQL standard and PostgreSQL extensions.