- 1. When You Need DuckDB
- 2. Architecture Deep Dive
- 3. Installation and Basic Usage
- 4. Comparison: DuckDB vs Alternative Technologies
- 5. Querying Parquet, CSV, and JSON Directly
- 6. Python Integration: Working with pandas and polars
- 7. Querying Remote Data Sources
- 8. Performance Benchmarks and Optimization Techniques
- 9. The DuckDB Extension Ecosystem
- 10. Operational Caveats
- 11. Failure Cases and Response Strategies
- 12. Real-World Usage Patterns
- 13. Operations Checklist
- 14. Closing Thoughts
- References

1. When You Need DuckDB
There is a scenario you run into constantly in data analysis workflows: hitting a MemoryError while trying to read a multi-GB CSV file with pandas, or provisioning a ClickHouse cluster just to run a simple aggregation query. DuckDB fills exactly this gap.
DuckDB is an in-process OLAP database developed by Mark Raasveldt and Hannes Muehleisen at CWI (Centrum Wiskunde & Informatica) in the Netherlands. It was first introduced in the 2019 SIGMOD paper "DuckDB: an Embeddable Analytical Database", and its core idea is applying SQLite's embedded philosophy to analytical workloads.
The scenarios where DuckDB is a good fit are as follows.
- When you want to run analytical queries over several GB to tens of GB on a local machine in sub-second time
- When you want to query Parquet, CSV, and JSON files with SQL immediately, without an ETL pipeline
- When you need large-scale aggregation analysis in a Jupyter Notebook that is faster than pandas
- When you need ad-hoc analysis for data quality validation inside a CI/CD pipeline
- When you want to analyze Parquet files stored in S3 directly, without a server
Conversely, the cases where DuckDB is not a good fit are just as clear.
- OLTP workloads that need many concurrent writes (use PostgreSQL or MySQL)
- Service backends where hundreds of users connect simultaneously (concurrency limits)
- Petabyte-scale distributed processing (requires a Spark or ClickHouse cluster)
- Production data stores that require high availability and replication
2. Architecture Deep Dive
2.1 Vectorized Execution Engine
The secret behind DuckDB's performance is its vectorized execution engine. Traditional row-based databases (for example PostgreSQL and MySQL) use the Volcano model and process one row at a time. That approach carries heavy function-call overhead, disrupts CPU branch prediction, and makes SIMD instructions hard to exploit.
DuckDB bundles 2048 values (the default) into a vector and processes them at once. This yields the following benefits.
- Maximum CPU cache efficiency: vectors are sized so they fit entirely in the L1/L2 cache, which minimizes cache misses
- SIMD utilization: the compiler can automatically translate vector operations into SIMD instructions (AVX2, AVX-512)
- Lower function-call overhead: operators are invoked per vector rather than per row, so the overhead drops to 1/2048
2.2 Columnar Storage Layout
Internally DuckDB stores data column by column. Because analytical queries usually touch only a subset of all columns, not reading the unnecessary columns saves a great deal of I/O.
-- This query reads only the name and amount columns
-- A row-based DB has to read every column, but DuckDB touches only 2 columns
SELECT name, SUM(amount)
FROM sales
GROUP BY name;
Columnar storage also compresses well, because values of the same type sit next to each other. DuckDB automatically applies lightweight compression techniques internally (Run-Length Encoding, Dictionary Encoding, BitPacking, and so on) to reduce memory usage.
2.3 Morsel-Driven Parallelism
DuckDB splits data into small chunks called "morsels" (roughly 10,000 rows) and hands them out dynamically to several worker threads. Compared with static partitioning this approach is resilient to load skew, and performance improves in proportion to the core count.
-- Check and adjust the parallelism settings
SELECT current_setting('threads');
-- Set the thread count explicitly
SET threads TO 8;
-- Set the per-worker memory
SET memory_limit = '4GB';
2.4 Out-of-Core Processing
Out-of-core processing, introduced in DuckDB 0.8, spills intermediate results to disk while handling a dataset larger than available memory. It makes analysis beyond the memory limit possible.
-- Set the temp directory (the spill target)
SET temp_directory = '/tmp/duckdb_spill';
-- Set the memory limit (anything above this value spills to disk)
SET memory_limit = '2GB';
-- Run a large sort with out-of-core processing
SELECT *
FROM large_table
ORDER BY timestamp_col;
3. Installation and Basic Usage
3.1 Python Installation and Basic Queries
The most common environment for DuckDB is Python. It installs with a single pip line and has no external dependencies at all.
# Install
# pip install duckdb
import duckdb
# Create an in-memory database
con = duckdb.connect()
# Run a basic query
result = con.execute("""
SELECT
range AS id,
'user_' || range AS name,
random() * 1000 AS score
FROM range(1000000)
""").fetchdf()
print(f"Row count: {len(result)}")
print(result.head())
# Aggregation query
con.execute("""
CREATE TABLE sales AS
SELECT
range AS id,
CASE WHEN random() < 0.3 THEN 'electronics'
WHEN random() < 0.6 THEN 'clothing'
ELSE 'food' END AS category,
(random() * 500)::INTEGER AS amount,
DATE '2025-01-01' + INTERVAL (range % 365) DAY AS sale_date
FROM range(10000000)
""")
# Monthly revenue aggregation by category
result = con.execute("""
SELECT
category,
DATE_TRUNC('month', sale_date) AS month,
COUNT(*) AS cnt,
SUM(amount) AS total_amount,
AVG(amount)::INTEGER AS avg_amount
FROM sales
GROUP BY category, DATE_TRUNC('month', sale_date)
ORDER BY month, total_amount DESC
""").fetchdf()
print(result)
3.2 CLI Usage
DuckDB also ships a standalone CLI. It is extremely handy for data exploration and one-off analysis.
# Install (macOS)
brew install duckdb
# Start in in-memory mode
duckdb
# Start with a file-based database
duckdb my_analytics.duckdb
Basic usage examples in the CLI are as follows.
-- Create a table and insert data
CREATE TABLE metrics (
timestamp TIMESTAMP,
server_id VARCHAR,
cpu_usage DOUBLE,
memory_usage DOUBLE
);
INSERT INTO metrics
SELECT
TIMESTAMP '2026-01-01' + INTERVAL (range * 60) SECOND,
'server-' || (range % 10)::VARCHAR,
50 + random() * 50,
30 + random() * 60
FROM range(100000);
-- CPU usage statistics per server
SELECT
server_id,
MIN(cpu_usage)::DECIMAL(5,2) AS min_cpu,
AVG(cpu_usage)::DECIMAL(5,2) AS avg_cpu,
MAX(cpu_usage)::DECIMAL(5,2) AS max_cpu,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY cpu_usage)::DECIMAL(5,2) AS p95_cpu
FROM metrics
GROUP BY server_id
ORDER BY avg_cpu DESC;
-- Hourly anomaly detection (CPU 95% or higher)
SELECT
DATE_TRUNC('hour', timestamp) AS hour,
COUNT(*) AS anomaly_count,
LIST(DISTINCT server_id) AS affected_servers
FROM metrics
WHERE cpu_usage > 95
GROUP BY DATE_TRUNC('hour', timestamp)
HAVING COUNT(*) > 5
ORDER BY anomaly_count DESC;
3.3 Node.js Integration
DuckDB can be used from Node.js as well. It is useful for handling analytical queries in serverless functions or API endpoints.
// npm install duckdb
const duckdb = require('duckdb')
// Create an in-memory database
const db = new duckdb.Database(':memory:')
const con = db.connect()
// Promise wrapper function
function query(sql) {
return new Promise((resolve, reject) => {
con.all(sql, (err, rows) => {
if (err) reject(err)
else resolve(rows)
})
})
}
async function main() {
// Query the Parquet file directly
const results = await query(`
SELECT
category,
COUNT(*) as count,
SUM(amount) as total
FROM read_parquet('sales_data.parquet')
GROUP BY category
ORDER BY total DESC
LIMIT 10
`)
console.log('Top categories:', results)
// Load data from CSV, then analyze
await query(`
CREATE TABLE logs AS
SELECT * FROM read_csv_auto('access_logs.csv')
`)
const hourlyStats = await query(`
SELECT
DATE_TRUNC('hour', timestamp) AS hour,
COUNT(*) AS request_count,
AVG(response_time_ms) AS avg_response_ms
FROM logs
GROUP BY DATE_TRUNC('hour', timestamp)
ORDER BY hour
`)
console.log('Hourly stats:', hourlyStats)
}
main().catch(console.error)
4. Comparison: DuckDB vs Alternative Technologies
There are many tools for handling analytical workloads. You need an accurate understanding of each tool's characteristics in order to make the right choice.
4.1 Overall Comparison Table
| Item | DuckDB | SQLite | ClickHouse | Polars | Pandas |
|---|---|---|---|---|---|
| Design goal | Embedded OLAP | Embedded OLTP | Distributed OLAP server | DataFrame analysis | DataFrame analysis |
| Storage layout | Columnar | Row-based | Columnar | Columnar | Columnar |
| Execution model | Vectorized | Row at a time | Vectorized | Vectorized | Mixed row/block |
| Query language | SQL (PostgreSQL compatible) | SQL (own dialect) | SQL (own dialect) | Python API/SQL | Python API |
| Concurrency | Single writer/multiple readers | Single writer/multiple readers | Multiple writers/readers | Not applicable | Not applicable |
| Server required | Not required (in-process) | Not required (in-process) | Required (server process) | Not required (library) | Not required (library) |
| 10GB CSV aggregation | About 3 seconds | About 60 seconds or more | About 1 second | About 5 seconds | About 30 seconds (OOM risk) |
| Memory efficiency | High (out-of-core) | Moderate | Very high | High | Low |
| Scalability | Single node | Single node | Horizontal scaling (cluster) | Single node | Single node |
| Installation difficulty | Very easy | Built in | Moderate (server setup) | Easy | Very easy |
| Parquet support | Native | Not supported | Native | Native | Requires pyarrow |
| Suitable data scale | MB to tens of GB | KB to a few GB | GB to PB | MB to tens of GB | MB to a few GB |
4.2 Key Decision Criteria
When you should choose DuckDB:
- When you want to analyze with SQL and start immediately without installing a server
- When you need to query Parquet/CSV files directly
- When you need large-scale data exploration in a Jupyter Notebook
When you should choose ClickHouse:
- When many concurrent users run analytical queries
- When you have to process petabyte-scale data
- When real-time data ingestion and analysis are both needed at once
When you should choose Polars:
- When you are building a data transformation pipeline on top of a Python API
- When you need complex data transformations optimized through lazy evaluation
- When you prefer a programmatic approach over SQL
5. Querying Parquet, CSV, and JSON Directly
One of DuckDB's most powerful features is that it can query external files directly with SQL, without loading them into a table. This is called "zero-ETL" analysis.
5.1 Querying Parquet Files
Parquet is the format that pairs best with DuckDB. Because it is a columnar format it meshes naturally with DuckDB's columnar engine, and column pruning and row group filtering (predicate pushdown) are applied automatically.
import duckdb
con = duckdb.connect()
# Query a single Parquet file
result = con.execute("""
SELECT
customer_region,
COUNT(*) AS order_count,
SUM(total_amount) AS revenue,
AVG(total_amount)::DECIMAL(10,2) AS avg_order_value
FROM read_parquet('orders_2025.parquet')
WHERE order_date >= '2025-06-01'
GROUP BY customer_region
ORDER BY revenue DESC
""").fetchdf()
# Query several Parquet files at once with a wildcard
result = con.execute("""
SELECT
filename,
DATE_TRUNC('month', event_time) AS month,
COUNT(*) AS event_count
FROM read_parquet('events/year=2025/month=*/data_*.parquet',
filename=true,
hive_partitioning=true)
GROUP BY filename, DATE_TRUNC('month', event_time)
""").fetchdf()
# Read only the Parquet metadata (does not read the file)
metadata = con.execute("""
SELECT * FROM parquet_metadata('orders_2025.parquet')
""").fetchdf()
print(f"Row group count: {len(metadata)}")
print(f"Columns: {metadata['path_in_schema'].unique()}")
# Inspect the Parquet schema
schema = con.execute("""
SELECT * FROM parquet_schema('orders_2025.parquet')
""").fetchdf()
print(schema)
5.2 Querying CSV Files
CSV files can also be queried right away through schema auto-detection.
# CSV auto-detection query
result = con.execute("""
SELECT *
FROM read_csv_auto('sales_data.csv')
LIMIT 10
""").fetchdf()
# Explicit schema (more stable on large files)
result = con.execute("""
SELECT
product_name,
SUM(quantity) AS total_qty,
SUM(price * quantity) AS total_revenue
FROM read_csv('sales_data.csv',
columns = {
'order_id': 'INTEGER',
'product_name': 'VARCHAR',
'quantity': 'INTEGER',
'price': 'DECIMAL(10,2)',
'order_date': 'DATE'
},
dateformat = '%Y-%m-%d',
header = true,
delim = ','
)
GROUP BY product_name
ORDER BY total_revenue DESC
LIMIT 20
""").fetchdf()
# Read several CSV files at once with a glob pattern
result = con.execute("""
SELECT COUNT(*) AS total_rows
FROM read_csv_auto('logs/access_log_2025_*.csv')
""").fetchone()
print(f"Total log rows: {result[0]:,}")
5.3 Querying JSON Files
# Query a JSON file
result = con.execute("""
SELECT
json_extract_string(data, '$.user.name') AS user_name,
json_extract_string(data, '$.event_type') AS event_type,
json_extract(data, '$.metadata.duration')::INTEGER AS duration_ms
FROM read_json_auto('events.json')
WHERE json_extract_string(data, '$.event_type') = 'page_view'
""").fetchdf()
# Query an NDJSON (Newline Delimited JSON) file
result = con.execute("""
SELECT
timestamp,
level,
message,
service
FROM read_json_auto('application.ndjson', format='newline_delimited')
WHERE level = 'ERROR'
ORDER BY timestamp DESC
LIMIT 100
""").fetchdf()
5.4 Multi-Format Joins
You can join files in different formats inside a single query. This is where DuckDB's real strength shows.
# Join Parquet order data + CSV customer data + JSON config data
result = con.execute("""
SELECT
c.customer_name,
c.tier,
COUNT(o.order_id) AS order_count,
SUM(o.amount) AS total_spent,
AVG(o.amount)::DECIMAL(10,2) AS avg_order
FROM read_parquet('orders/*.parquet') o
JOIN read_csv_auto('customers.csv') c
ON o.customer_id = c.id
WHERE o.order_date >= '2025-01-01'
GROUP BY c.customer_name, c.tier
HAVING COUNT(o.order_id) >= 5
ORDER BY total_spent DESC
LIMIT 20
""").fetchdf()
6. Python Integration: Working with pandas and polars
6.1 Zero-Copy Integration with pandas DataFrames
DuckDB can query a pandas DataFrame directly as if it were a SQL table. Zero-copy data transfer through Apache Arrow means even a large DataFrame can be analyzed immediately, without being copied.
import duckdb
import pandas as pd
import numpy as np
# Create pandas DataFrames
df_orders = pd.DataFrame({
'order_id': range(1, 1000001),
'customer_id': np.random.randint(1, 10001, 1000000),
'amount': np.random.uniform(10, 500, 1000000).round(2),
'category': np.random.choice(['electronics', 'clothing', 'food', 'books'], 1000000),
'order_date': pd.date_range('2025-01-01', periods=1000000, freq='30s')
})
df_customers = pd.DataFrame({
'id': range(1, 10001),
'name': [f'Customer_{i}' for i in range(1, 10001)],
'tier': np.random.choice(['gold', 'silver', 'bronze'], 10000)
})
# Query the DataFrames directly with SQL (the variable name becomes the table name)
result = duckdb.sql("""
SELECT
c.tier,
o.category,
COUNT(*) AS order_count,
SUM(o.amount)::DECIMAL(12,2) AS total_revenue,
AVG(o.amount)::DECIMAL(10,2) AS avg_order_value
FROM df_orders o
JOIN df_customers c ON o.customer_id = c.id
GROUP BY c.tier, o.category
ORDER BY c.tier, total_revenue DESC
""").fetchdf()
print(result)
6.2 Working with polars DataFrames
A polars DataFrame can be queried the same way. Because polars already uses the Arrow format, there is no data conversion overhead at all.
import duckdb
import polars as pl
# Create a polars DataFrame
df_events = pl.DataFrame({
'event_id': range(1, 5000001),
'user_id': [i % 100000 for i in range(1, 5000001)],
'event_type': ['click', 'view', 'purchase', 'search'] * 1250000,
'duration_ms': [abs(int(x)) for x in (100 + 50 * i % 7 for i in range(5000000))],
'timestamp': pl.date_range(
pl.datetime(2025, 1, 1),
pl.datetime(2025, 12, 31),
eager=True
).sample(5000000, with_replacement=True).sort()
})
# Query the polars DataFrame with DuckDB
result = duckdb.sql("""
SELECT
event_type,
DATE_TRUNC('month', timestamp) AS month,
COUNT(*) AS event_count,
AVG(duration_ms)::INTEGER AS avg_duration,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY duration_ms)::INTEGER AS p95_duration
FROM df_events
GROUP BY event_type, DATE_TRUNC('month', timestamp)
ORDER BY month, event_count DESC
""").fetchdf()
# Convert the result back to polars
result_pl = pl.from_pandas(result)
print(result_pl)
6.3 DuckDB vs pandas Performance
Let us measure the performance difference between pandas and DuckDB on the same task directly.
import duckdb
import pandas as pd
import numpy as np
import time
# Generate 10 million rows of test data
n_rows = 10_000_000
df = pd.DataFrame({
'group_a': np.random.choice(['A', 'B', 'C', 'D', 'E'], n_rows),
'group_b': np.random.randint(1, 101, n_rows),
'value': np.random.uniform(0, 1000, n_rows)
})
# Group-by aggregation with pandas
start = time.time()
pandas_result = df.groupby(['group_a', 'group_b']).agg(
count=('value', 'count'),
sum_val=('value', 'sum'),
mean_val=('value', 'mean'),
std_val=('value', 'std')
).reset_index().sort_values('sum_val', ascending=False).head(20)
pandas_time = time.time() - start
# The same aggregation with DuckDB
start = time.time()
duckdb_result = duckdb.sql("""
SELECT
group_a,
group_b,
COUNT(*) AS count,
SUM(value)::DECIMAL(15,2) AS sum_val,
AVG(value)::DECIMAL(10,2) AS mean_val,
STDDEV(value)::DECIMAL(10,2) AS std_val
FROM df
GROUP BY group_a, group_b
ORDER BY sum_val DESC
LIMIT 20
""").fetchdf()
duckdb_time = time.time() - start
print(f"pandas: {pandas_time:.3f}s")
print(f"DuckDB: {duckdb_time:.3f}s")
print(f"Speedup: {pandas_time / duckdb_time:.1f}x")
# DuckDB is typically 3-10x faster
7. Querying Remote Data Sources
7.1 Querying S3 Parquet Files Directly
With DuckDB's httpfs extension you can query Parquet files stored in S3 as if they were local files. According to the official DuckDB documentation (duckdb.org/docs/extensions/httpfs), this extension uses HTTP Range requests to download only the byte ranges it needs.
import duckdb
con = duckdb.connect()
# Install and load the httpfs extension
con.execute("INSTALL httpfs")
con.execute("LOAD httpfs")
# Configure AWS credentials
con.execute("""
SET s3_region = 'ap-northeast-2';
SET s3_access_key_id = 'YOUR_ACCESS_KEY';
SET s3_secret_access_key = 'YOUR_SECRET_KEY';
""")
# Query the S3 Parquet files directly
result = con.execute("""
SELECT
DATE_TRUNC('day', event_time) AS day,
event_type,
COUNT(*) AS event_count
FROM read_parquet('s3://my-data-lake/events/year=2025/month=12/*.parquet',
hive_partitioning=true)
GROUP BY DATE_TRUNC('day', event_time), event_type
ORDER BY day, event_count DESC
""").fetchdf()
print(result)
7.2 Querying Directly from an HTTP URL
You can also query a public dataset directly by URL.
con = duckdb.connect()
con.execute("INSTALL httpfs")
con.execute("LOAD httpfs")
# Query a CSV directly from an HTTP URL
result = con.execute("""
SELECT
Country,
SUM(Confirmed) AS total_confirmed,
SUM(Deaths) AS total_deaths,
(SUM(Deaths)::FLOAT / NULLIF(SUM(Confirmed), 0) * 100)::DECIMAL(5,2) AS fatality_rate
FROM read_csv_auto(
'https://raw.githubusercontent.com/datasets/covid-19/main/data/countries-aggregated.csv'
)
WHERE Date >= '2023-01-01'
GROUP BY Country
ORDER BY total_confirmed DESC
LIMIT 20
""").fetchdf()
print(result)
7.3 MotherDuck Cloud Integration
MotherDuck (motherduck.com) is DuckDB's cloud service, and it lets you wire local DuckDB and the cloud together in a hybrid setup. As introduced on the MotherDuck blog, it supports the pattern of starting locally and expanding into the cloud step by step.
import duckdb
# Connect to MotherDuck (an auth token is required)
con = duckdb.connect('md:my_database')
# Join local data with cloud data
result = con.execute("""
SELECT
local_table.id,
cloud_table.aggregated_value
FROM read_parquet('local_data.parquet') AS local_table
JOIN my_database.cloud_table
ON local_table.id = cloud_table.id
""").fetchdf()
8. Performance Benchmarks and Optimization Techniques
8.1 TPC-H Benchmark Results
In the TPC-H benchmark published in the DuckDB GitHub repository (github.com/duckdb/duckdb), DuckDB shows the following performance at Scale Factor 10 (about 10GB).
| Query | DuckDB (s) | SQLite (s) | pandas (s) | Notes |
|---|---|---|---|---|
| Q1 (pricing summary) | 0.4 | 28.5 | 12.3 | Simple aggregation, full scan |
| Q3 (shipping priority) | 0.8 | 45.2 | OOM | Join + aggregation |
| Q6 (revenue change) | 0.1 | 15.8 | 5.2 | Filter + aggregation |
| Q9 (product profitability) | 2.1 | 120+ | OOM | Complex multi-way join |
| Q18 (large volume customers) | 1.5 | 90+ | OOM | Subquery + aggregation |
Note: test environment - Apple M2 Pro, 16GB RAM, macOS. DuckDB 1.1, SQLite 3.45, pandas 2.2
8.2 Query Optimization Techniques
-- 1. Check the execution plan with EXPLAIN ANALYZE
EXPLAIN ANALYZE
SELECT customer_id, SUM(amount)
FROM orders
WHERE order_date >= '2025-01-01'
GROUP BY customer_id
HAVING SUM(amount) > 10000;
-- 2. Enable profiling
PRAGMA enable_profiling;
PRAGMA profiling_output = '/tmp/duckdb_profile.json';
PRAGMA profiling_mode = 'detailed';
-- Check the profile after running the query
SELECT customer_id, SUM(amount)
FROM orders
GROUP BY customer_id;
-- 3. Check statistics
CALL pragma_storage_info('orders');
-- 4. Use indexes (DuckDB supports ART indexes)
-- Caution: DuckDB is an OLAP-optimized DB, so an index is not always beneficial
CREATE INDEX idx_orders_date ON orders(order_date);
-- 5. Export to partitioned Parquet (improves query performance)
COPY (
SELECT * FROM orders
) TO 'orders_partitioned' (
FORMAT PARQUET,
PARTITION_BY (order_year, order_month),
OVERWRITE_OR_IGNORE true
);
8.3 Memory Optimization Strategy
-- Check current memory usage
SELECT * FROM duckdb_memory();
-- Set the memory limit
SET memory_limit = '8GB';
-- Set the temp directory (spills to disk when memory is exceeded)
SET temp_directory = '/tmp/duckdb_temp';
-- Enable the progress bar (for monitoring long-running queries)
SET enable_progress_bar = true;
SET enable_progress_bar_print = true;
-- Save memory by dropping unnecessary columns
-- Bad: SELECT * loads every column into memory
-- Good: name only the columns you need
SELECT order_id, amount, order_date
FROM read_parquet('large_orders.parquet');
8.4 Large-Scale Data Processing Patterns
import duckdb
con = duckdb.connect()
con.execute("SET memory_limit = '4GB'")
con.execute("SET temp_directory = '/tmp/duckdb_spill'")
# Memory-efficient analysis through chunked processing
con.execute("""
CREATE TABLE daily_summary AS
SELECT
DATE_TRUNC('day', event_time) AS day,
event_type,
COUNT(*) AS cnt,
SUM(value) AS total_value
FROM read_parquet('s3://data-lake/events/year=2025/**/*.parquet',
hive_partitioning=true)
GROUP BY DATE_TRUNC('day', event_time), event_type
""")
# Export the result to Parquet
con.execute("""
COPY daily_summary
TO 'daily_summary.parquet' (FORMAT PARQUET, COMPRESSION ZSTD)
""")
# Release memory once processing is done
con.execute("DROP TABLE daily_summary")
9. The DuckDB Extension Ecosystem
DuckDB can be extended with functionality through its extension system. According to the DuckDB extension documentation (duckdb.org/docs/extensions/overview), the main extensions are as follows.
9.1 Main Extensions
| Extension | Purpose | Install command |
|---|---|---|
| httpfs | Remote S3/HTTP file access | INSTALL httpfs |
| json | Enhanced JSON handling | Built in |
| parquet | Parquet read/write | Built in |
| icu | Internationalization/collation/locale | INSTALL icu |
| fts | Full-Text Search | INSTALL fts |
| spatial | Spatial data processing (similar to PostGIS) | INSTALL spatial |
| postgres_scanner | Query PostgreSQL tables directly | INSTALL postgres_scanner |
| mysql_scanner | Query MySQL tables directly | INSTALL mysql_scanner |
| sqlite_scanner | Query SQLite files directly | INSTALL sqlite_scanner |
| excel | Read Excel files | INSTALL excel |
9.2 Direct PostgreSQL Integration
import duckdb
con = duckdb.connect()
con.execute("INSTALL postgres_scanner")
con.execute("LOAD postgres_scanner")
# Connect directly to PostgreSQL and query
con.execute("""
CALL postgres_attach(
'host=localhost port=5432 dbname=mydb user=analyst password=secret'
)
""")
# Analyze PostgreSQL tables with DuckDB SQL
result = con.execute("""
SELECT
DATE_TRUNC('month', created_at) AS month,
status,
COUNT(*) AS order_count,
SUM(total_amount)::DECIMAL(15,2) AS revenue
FROM postgres_scan('public', 'orders')
WHERE created_at >= '2025-01-01'
GROUP BY DATE_TRUNC('month', created_at), status
ORDER BY month
""").fetchdf()
# Join local Parquet with PostgreSQL data
result = con.execute("""
SELECT
p.product_name,
o.order_count,
o.total_revenue
FROM read_parquet('product_catalog.parquet') p
JOIN (
SELECT product_id, COUNT(*) AS order_count, SUM(amount) AS total_revenue
FROM postgres_scan('public', 'order_items')
GROUP BY product_id
) o ON p.id = o.product_id
ORDER BY o.total_revenue DESC
LIMIT 20
""").fetchdf()
print(result)
10. Operational Caveats
10.1 Concurrency Limits
DuckDB is an embedded database that runs inside a single process. As the SIGMOD 2019 paper states explicitly, OLTP-level concurrency is not a design goal.
Key constraints:
- Single write connection: only one process can open the database in write mode
- Multiple readers allowed: several processes can open it read-only at the same time
- Unsuitable as a web server backend: an API server with many concurrent requests hits a connection bottleneck
import duckdb
# Several processes connecting in read-only mode
con = duckdb.connect('analytics.duckdb', read_only=True)
# When writes are needed, only a single process may connect
con_write = duckdb.connect('analytics.duckdb', read_only=False)
10.2 Memory Management Strategy
DuckDB processes in memory by default, so memory management matters a great deal.
import duckdb
import os
# Allocate 70% of system memory to DuckDB (the rest is for the OS and other processes)
total_memory_gb = os.sysconf('SC_PAGE_SIZE') * os.sysconf('SC_PHYS_PAGES') / (1024**3)
duckdb_memory = int(total_memory_gb * 0.7)
con = duckdb.connect()
con.execute(f"SET memory_limit = '{duckdb_memory}GB'")
con.execute("SET temp_directory = '/tmp/duckdb_spill'")
# Monitor memory usage
def check_memory():
result = con.execute("""
SELECT
tag,
memory_usage_bytes / (1024*1024) AS memory_mb
FROM duckdb_memory()
ORDER BY memory_usage_bytes DESC
""").fetchdf()
print(result)
return result
check_memory()
10.3 Data Durability and Backup
import duckdb
import shutil
from datetime import datetime
# Use a file-based database (to obtain durability)
con = duckdb.connect('production_analytics.duckdb')
# Check WAL (Write-Ahead Log) mode
# DuckDB uses its own WAL implementation
con.execute("""
CREATE TABLE IF NOT EXISTS etl_log (
run_id INTEGER,
table_name VARCHAR,
rows_processed BIGINT,
started_at TIMESTAMP,
completed_at TIMESTAMP,
status VARCHAR
)
""")
# Backup (copy the DuckDB file directly)
def backup_database(db_path, backup_dir):
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = f"{backup_dir}/analytics_backup_{timestamp}.duckdb"
# Copy after closing the write connection
shutil.copy2(db_path, backup_path)
print(f"Backup complete: {backup_path}")
return backup_path
# Create a SQL dump with EXPORT DATABASE
con.execute("""
EXPORT DATABASE '/tmp/analytics_export' (FORMAT PARQUET)
""")
11. Failure Cases and Response Strategies
11.1 Out-of-Memory (OOM) Problems
Symptom: during a large GROUP BY or JOIN the process gets killed, or an OutOfMemoryException is raised
import duckdb
con = duckdb.connect()
# Problem: a high-cardinality GROUP BY over 1 billion rows
# This query can use an excessive amount of memory
try:
result = con.execute("""
SELECT user_id, COUNT(DISTINCT session_id), AVG(duration)
FROM read_parquet('huge_events_*.parquet')
GROUP BY user_id
""").fetchdf()
except duckdb.OutOfMemoryException:
print("Out of memory!")
# Solution 1: enable out-of-core processing
con.execute("SET memory_limit = '4GB'")
con.execute("SET temp_directory = '/ssd/duckdb_temp'") # SSD recommended
# Solution 2: staged processing (using partition pruning)
for month in range(1, 13):
result = con.execute(f"""
SELECT user_id, COUNT(DISTINCT session_id), AVG(duration)
FROM read_parquet('events/month={month:02d}/*.parquet',
hive_partitioning=true)
GROUP BY user_id
""").fetchdf()
result.to_parquet(f'partial_results/month_{month:02d}.parquet')
# Combine the partial results
final = con.execute("""
SELECT user_id,
SUM(count_distinct_session) AS total_sessions,
AVG(avg_duration) AS overall_avg_duration
FROM read_parquet('partial_results/month_*.parquet')
GROUP BY user_id
""").fetchdf()
# Solution 3: use approximate aggregation (accuracy vs memory trade-off)
result = con.execute("""
SELECT
user_segment,
APPROX_COUNT_DISTINCT(user_id) AS approx_users,
APPROX_QUANTILE(amount, 0.5) AS median_amount,
APPROX_QUANTILE(amount, 0.95) AS p95_amount
FROM read_parquet('huge_events_*.parquet')
GROUP BY user_segment
""").fetchdf()
11.2 Concurrent Write Conflicts
Symptom: when several processes try to write to the same .duckdb file at once, IOException: Could not set lock on file is raised
import duckdb
import time
import random
# Problem: multiple ETL jobs try to write to the same DB at once
# Solution: serialize writes through a single process
# Pattern 1: file lock + retry
def safe_write(db_path, query, max_retries=5):
for attempt in range(max_retries):
try:
con = duckdb.connect(db_path)
con.execute(query)
con.close()
return True
except duckdb.IOException as e:
if "lock" in str(e).lower():
wait_time = (2 ** attempt) + random.uniform(0, 1)
print(f"Lock conflict, retrying in {wait_time:.1f}s ({attempt + 1}/{max_retries})")
time.sleep(wait_time)
else:
raise
raise Exception("Exceeded the maximum retry count")
# Pattern 2: each process writes its own file and they are merged later
def write_to_individual_file(worker_id, data_path):
output_path = f'worker_output/worker_{worker_id}.parquet'
con = duckdb.connect() # Process in memory
con.execute(f"""
COPY (
SELECT * FROM read_parquet('{data_path}')
WHERE some_condition
) TO '{output_path}' (FORMAT PARQUET)
""")
con.close()
# Merge step
def merge_results():
con = duckdb.connect('final_analytics.duckdb')
con.execute("""
CREATE OR REPLACE TABLE results AS
SELECT * FROM read_parquet('worker_output/worker_*.parquet')
""")
con.close()
11.3 Parquet Schema Mismatch
Symptom: reading several Parquet files together raises Binder Error: Types mismatch or a missing-column error
import duckdb
con = duckdb.connect()
# Problem: reading Parquet files with different schemas together
# The case where columns were added or types changed over time
# Solution 1: use the union_by_name option
result = con.execute("""
SELECT *
FROM read_parquet(
'events/year=*/month=*/*.parquet',
hive_partitioning = true,
union_by_name = true -- Merge automatically by column name
)
LIMIT 10
""").fetchdf()
# Solution 2: check the schema, then write a safe query
schema_info = con.execute("""
SELECT file_name, name, type
FROM parquet_schema('events/year=2025/month=01/*.parquet')
""").fetchdf()
print("Schema check:", schema_info)
# Solution 3: handle missing columns with COALESCE
result = con.execute("""
SELECT
event_id,
event_type,
COALESCE(new_column, 'default_value') AS new_column,
timestamp
FROM read_parquet(
'events/**/*.parquet',
union_by_name = true
)
""").fetchdf()
12. Real-World Usage Patterns
12.1 ETL Pipeline
import duckdb
from datetime import datetime
def daily_etl_pipeline(date_str):
"""Daily ETL pipeline: S3 source -> transform -> save as Parquet"""
con = duckdb.connect()
con.execute("INSTALL httpfs; LOAD httpfs;")
con.execute("""
SET s3_region = 'ap-northeast-2';
SET s3_access_key_id = 'YOUR_KEY';
SET s3_secret_access_key = 'YOUR_SECRET';
""")
# Step 1: read the raw data + cleansing
con.execute(f"""
CREATE TEMP TABLE raw_events AS
SELECT
event_id,
LOWER(TRIM(event_type)) AS event_type,
user_id,
COALESCE(amount, 0) AS amount,
TRY_CAST(timestamp AS TIMESTAMP) AS event_time,
properties
FROM read_parquet(
's3://raw-data/events/date={date_str}/*.parquet'
)
WHERE TRY_CAST(timestamp AS TIMESTAMP) IS NOT NULL
AND user_id IS NOT NULL
""")
# Step 2: map user segments
con.execute("""
CREATE TEMP TABLE enriched_events AS
SELECT
e.*,
u.segment,
u.registration_date,
DATEDIFF('day', u.registration_date, e.event_time) AS days_since_registration
FROM raw_events e
LEFT JOIN read_parquet('s3://dim-data/users/latest/*.parquet') u
ON e.user_id = u.user_id
""")
# Step 3: build the aggregate table
con.execute(f"""
COPY (
SELECT
DATE_TRUNC('hour', event_time) AS hour,
segment,
event_type,
COUNT(*) AS event_count,
COUNT(DISTINCT user_id) AS unique_users,
SUM(amount) AS total_amount,
AVG(amount)::DECIMAL(10,2) AS avg_amount
FROM enriched_events
GROUP BY
DATE_TRUNC('hour', event_time),
segment,
event_type
) TO 's3://processed-data/hourly_summary/date={date_str}/data.parquet'
(FORMAT PARQUET, COMPRESSION ZSTD)
""")
row_count = con.execute("SELECT COUNT(*) FROM enriched_events").fetchone()[0]
print(f"[{date_str}] ETL complete: {row_count:,} rows processed")
con.close()
return row_count
# Run
daily_etl_pipeline('2025-12-15')
12.2 Data Quality Validation
import duckdb
def validate_data_quality(parquet_path):
"""A collection of data quality validation queries"""
con = duckdb.connect()
# Basic statistics
stats = con.execute(f"""
SELECT
COUNT(*) AS total_rows,
COUNT(DISTINCT user_id) AS unique_users,
MIN(event_time) AS earliest_event,
MAX(event_time) AS latest_event,
COUNT(*) FILTER (WHERE user_id IS NULL) AS null_user_count,
COUNT(*) FILTER (WHERE amount < 0) AS negative_amount_count,
COUNT(*) FILTER (WHERE event_type NOT IN
('click', 'view', 'purchase', 'search')) AS unknown_event_count
FROM read_parquet('{parquet_path}')
""").fetchdf()
print("=== Basic statistics ===")
print(stats.to_string(index=False))
# Duplicate check
duplicates = con.execute(f"""
SELECT
event_id,
COUNT(*) AS dup_count
FROM read_parquet('{parquet_path}')
GROUP BY event_id
HAVING COUNT(*) > 1
ORDER BY dup_count DESC
LIMIT 10
""").fetchdf()
if len(duplicates) > 0:
print(f"\n[WARN] Duplicate events found: {len(duplicates)}")
print(duplicates)
else:
print("\n[PASS] No duplicate events")
# Time continuity check (detect empty hour buckets)
gaps = con.execute(f"""
WITH hourly AS (
SELECT DATE_TRUNC('hour', event_time) AS hour, COUNT(*) AS cnt
FROM read_parquet('{parquet_path}')
GROUP BY DATE_TRUNC('hour', event_time)
),
expected_hours AS (
SELECT UNNEST(generate_series(
(SELECT MIN(hour) FROM hourly),
(SELECT MAX(hour) FROM hourly),
INTERVAL 1 HOUR
)) AS hour
)
SELECT e.hour AS missing_hour
FROM expected_hours e
LEFT JOIN hourly h ON e.hour = h.hour
WHERE h.hour IS NULL
ORDER BY e.hour
""").fetchdf()
if len(gaps) > 0:
print(f"\n[WARN] Hours with missing data: {len(gaps)}")
print(gaps.head(10))
else:
print("\n[PASS] Time continuity is intact")
con.close()
validate_data_quality('events/year=2025/month=12/*.parquet')
12.3 Aggregate Views for BI Dashboards
import duckdb
def create_dashboard_views(db_path):
"""Create pre-aggregated views for a BI dashboard"""
con = duckdb.connect(db_path)
# Daily KPI view
con.execute("""
CREATE OR REPLACE VIEW v_daily_kpi AS
SELECT
DATE_TRUNC('day', order_time) AS date,
COUNT(DISTINCT customer_id) AS dau,
COUNT(*) AS total_orders,
SUM(amount)::DECIMAL(15,2) AS gmv,
AVG(amount)::DECIMAL(10,2) AS aov,
COUNT(*) FILTER (WHERE is_first_order) AS new_customer_orders,
COUNT(*) FILTER (WHERE is_refunded) AS refund_count,
(COUNT(*) FILTER (WHERE is_refunded)::FLOAT /
NULLIF(COUNT(*), 0) * 100)::DECIMAL(5,2) AS refund_rate
FROM orders
GROUP BY DATE_TRUNC('day', order_time)
ORDER BY date
""")
# Cohort analysis view
con.execute("""
CREATE OR REPLACE VIEW v_cohort_retention AS
WITH first_purchase AS (
SELECT
customer_id,
DATE_TRUNC('month', MIN(order_time)) AS cohort_month
FROM orders
GROUP BY customer_id
),
monthly_activity AS (
SELECT
customer_id,
DATE_TRUNC('month', order_time) AS activity_month
FROM orders
GROUP BY customer_id, DATE_TRUNC('month', order_time)
)
SELECT
fp.cohort_month,
DATEDIFF('month', fp.cohort_month, ma.activity_month) AS months_since_first,
COUNT(DISTINCT ma.customer_id) AS active_users,
COUNT(DISTINCT ma.customer_id)::FLOAT /
NULLIF(COUNT(DISTINCT fp.customer_id) FILTER (
WHERE DATEDIFF('month', fp.cohort_month, ma.activity_month) = 0
), 0) AS retention_rate
FROM first_purchase fp
JOIN monthly_activity ma ON fp.customer_id = ma.customer_id
GROUP BY fp.cohort_month,
DATEDIFF('month', fp.cohort_month, ma.activity_month)
ORDER BY fp.cohort_month, months_since_first
""")
# Funnel analysis view
con.execute("""
CREATE OR REPLACE VIEW v_conversion_funnel AS
WITH step1 AS (
SELECT DISTINCT user_id, DATE_TRUNC('day', event_time) AS day
FROM events WHERE event_type = 'page_view'
),
step2 AS (
SELECT DISTINCT user_id, DATE_TRUNC('day', event_time) AS day
FROM events WHERE event_type = 'add_to_cart'
),
step3 AS (
SELECT DISTINCT user_id, DATE_TRUNC('day', event_time) AS day
FROM events WHERE event_type = 'checkout'
),
step4 AS (
SELECT DISTINCT user_id, DATE_TRUNC('day', event_time) AS day
FROM events WHERE event_type = 'purchase'
)
SELECT
s1.day,
COUNT(DISTINCT s1.user_id) AS page_view_users,
COUNT(DISTINCT s2.user_id) AS add_to_cart_users,
COUNT(DISTINCT s3.user_id) AS checkout_users,
COUNT(DISTINCT s4.user_id) AS purchase_users,
(COUNT(DISTINCT s4.user_id)::FLOAT /
NULLIF(COUNT(DISTINCT s1.user_id), 0) * 100)::DECIMAL(5,2) AS overall_conversion_rate
FROM step1 s1
LEFT JOIN step2 s2 ON s1.user_id = s2.user_id AND s1.day = s2.day
LEFT JOIN step3 s3 ON s2.user_id = s3.user_id AND s2.day = s3.day
LEFT JOIN step4 s4 ON s3.user_id = s4.user_id AND s3.day = s4.day
GROUP BY s1.day
ORDER BY s1.day
""")
print("Dashboard views created")
con.close()
create_dashboard_views('analytics.duckdb')
13. Operations Checklist
13.1 Pre-Adoption Checklist
- Does the data volume fit in a single machine's memory? (several GB to tens of GB recommended)
- Is there no concurrent-write requirement? (only a single writer is supported)
- Is this an OLAP workload? (aggregation, scan, and join oriented)
- Is high availability (HA) not required? (limited to a single node)
- Is the data format Parquet/CSV/JSON? (best supported)
13.2 Monitoring Items in Production
import duckdb
con = duckdb.connect('analytics.duckdb')
# 1. Monitor memory usage
memory_info = con.execute("""
SELECT
tag,
(memory_usage_bytes / 1024 / 1024)::INTEGER AS usage_mb
FROM duckdb_memory()
WHERE memory_usage_bytes > 0
ORDER BY memory_usage_bytes DESC
""").fetchdf()
print("Memory usage:", memory_info.to_string(index=False))
# 2. Check table sizes
table_sizes = con.execute("""
SELECT
table_name,
estimated_size / 1024 / 1024 AS estimated_size_mb,
column_count,
index_count
FROM duckdb_tables()
ORDER BY estimated_size DESC
""").fetchdf()
print("Table sizes:", table_sizes.to_string(index=False))
# 3. Check running queries (DuckDB 1.1+)
# DuckDB is single-process, so a long-running query can block other work
# Progress can be checked with enable_progress_bar
# 4. Check the database file size
import os
db_size = os.path.getsize('analytics.duckdb') / (1024 * 1024)
print(f"DB file size: {db_size:.1f} MB")
con.close()
13.3 Performance Optimization Checklist
- Did you name only the columns you need instead of
SELECT *? - Is
memory_limitset appropriately? (60-80% of system memory) - Is
temp_directorypointed at an SSD path? - Are the Parquet files partitioned appropriately?
- On a large join, did you filter the smaller table first?
- Did you check the execution plan with
EXPLAIN ANALYZE? - Can you make use of approximate functions such as
APPROX_COUNT_DISTINCT? - Did date partitioning cut down unnecessary file scans?
14. Closing Thoughts
DuckDB is a practical answer to the question "does analysis really require a distributed system?" The combination of a vectorized execution engine, columnar storage, and morsel-driven parallelism delivers remarkable analytical performance even on a single machine.
Key takeaways:
- Installation and usage are extremely simple: one line,
pip install duckdb, is all it takes - Everything is queried with SQL: Parquet, CSV, JSON, and even a pandas DataFrame can all be analyzed with a single SQL statement
- The performance is astonishing: on TPC-H it is 50-100x faster than SQLite and 3-10x faster than pandas
- You have to understand the constraints: no concurrent writes, limited to a single node, unsuitable for OLTP
DuckDB is not an all-purpose tool. But for local analysis, ETL pipelines, data quality validation, and rapid prototyping, it is currently the most efficient option available. It is fair to say it is a tool that absolutely belongs in the toolbox of data engineers and analysts.
References
- DuckDB Official Documentation - comprehensive documentation on installation, features, and extensions
- DuckDB: an Embeddable Analytical Database (SIGMOD 2019) - the original paper by Raasveldt and Muehleisen, with a detailed description of the vectorized execution engine and the architecture
- DuckDB GitHub Repository - source code, benchmarks, and issue tracker
- MotherDuck Blog - the DuckDB cloud service, use cases, and performance analysis
- DuckDB Extension Documentation - a detailed guide to extensions such as httpfs, spatial, and postgres_scanner