DEV Community

Cover image for DuckDB: What Actually Happens Under the Hood — Vectorized Execution, Push-Based Pipelines, and Morsel-Driven Parallelism
Syed Anzar
Syed Anzar

Posted on

DuckDB: What Actually Happens Under the Hood — Vectorized Execution, Push-Based Pipelines, and Morsel-Driven Parallelism

DuckDB: What Actually Happens Under the Hood — Vectorized Execution, Push-Based Pipelines, and Morsel-Driven Parallelism

You know DuckDB as "SQLite for analytics." You've probably run SELECT * FROM 'data.parquet' and watched it scan millions of rows in milliseconds. But do you know why it's that fast?

Most developers treat DuckDB as a black box: embed it, query it, get results. But the speed comes from three deliberate architectural choices that fundamentally differ from how PostgreSQL, SQLite, or even Spark execute queries.

Let's open the box.


The Three Pillars

Traditional DBs (PostgreSQL, SQLite) DuckDB
Row-at-a-time (Volcano iterator) Vectorized: 2,048 rows per batch
Pull-based: parent asks child for data Push-based: child pushes to parent
Centralized executor decides parallelism Morsel-driven: each operator self-parallelizes

These aren't optimizations. They're a different execution model.


Vectorized Execution: Why 2,048 Rows?

DuckDB doesn't process rows. It processes vectors — contiguous arrays of 2,048 values per column.

// Traditional row-at-a-time (PostgreSQL, SQLite)
for (row in table) {
    value = row.column;
    result = fn(value);
    output.append(result);
}
// 1 function call per row, terrible cache usage, no SIMD

// DuckDB vectorized
for (vector in table) {
    values = vector.column;        // 2048 values in contiguous memory
    results = fn_simd(values);     // 1 function call, SIMD-friendly
    output.append(results);
}
Enter fullscreen mode Exit fullscreen mode

Why 2,048?

  • Fits in L1/L2 cache (~16 KB for int64)
  • Power of 2 -> efficient bit operations
  • Large enough to amortize function call overhead
  • Small enough to avoid memory pressure

Each vector lives inside a DataChunk — the fundamental unit of data flow:

DataChunk:
  Column 0: [int1, int2, ..., int2048]   // 2048 integers
  Column 1: [str1, str2, ..., str2048]   // 2048 strings
  Column 2: [ts1,  ts2,  ..., ts2048]    // 2048 timestamps
  Count: 2048  (valid rows <= capacity)
Enter fullscreen mode Exit fullscreen mode

The count can be less than 2048 — that's how filters work. A WHERE clause doesn't remove rows; it just reduces the count field. The underlying memory stays allocated.


Push-Based Execution: Data Flows Downhill

Traditional databases use the Volcano iterator model (pull-based):

// Parent pulls from child
while (chunk = child.GetNext()) {
    process(chunk);
    parent_push(chunk);  // if parent exists
}
Enter fullscreen mode Exit fullscreen mode

Problem: the parent controls the pace. The child can't push multiple chunks ahead. You get pipeline bubbles — the parent waits for the child, the child waits for I/O, nobody overlaps work.

DuckDB flips it. Children push to parents:

// Child pushes to parent
void Child::Execute(DataChunk &input) {
    process_data();
    parent.Execute(output_chunk);  // push, don't wait
}
Enter fullscreen mode Exit fullscreen mode

What this enables

  1. True pipelining — A chunk flows through Filter -> Project -> Aggregate in one pass. No materialization between operators.

  2. Cache locality — Data stays hot in L1/L2 as it moves through the pipeline. The same 2048-value array gets transformed in place.

  3. Natural parallelism — Multiple pipelines run independently on different cores. No central scheduler bottleneck.

  4. Backpressure for free — If a parent's buffer fills, the child's Execute blocks. I/O pauses propagate naturally.


Morsel-Driven Parallelism: No Central Scheduler

Most parallel databases have a central executor that hands out work. DuckDB doesn't.

Instead, each operator decides its own parallelism based on the data it sees.

idx_t PhysicalOperator::EstimatedThreadCount() const {
    if (children.empty()) {
        // Table scan: parallelism = row groups / 2
        return max(cardinality / (ROW_GROUP_SIZE * 2), 1);
    }
    if (type == UNION) {
        // Sum children's threads
        return sum(child.EstimatedThreadCount());
    }
    // Other operators: max of children
    return max(child.EstimatedThreadCount());
}
Enter fullscreen mode Exit fullscreen mode

A table with 1M rows (8 row groups of ~122K each) on 4 cores:

Thread 1: row groups 0, 1
Thread 2: row groups 2, 3
Thread 3: row groups 4, 5
Thread 4: row groups 6, 7
-> All partial results combined
Enter fullscreen mode Exit fullscreen mode

No synchronization during processing. Threads only sync at pipeline boundaries (HashJoin build, HashAggregate, Order).

Pipeline breakers

Some operators must materialize and break the pipeline:

Operator Why it breaks
HashJoin (build side) Must build complete hash table before probing
HashAggregate Must see all rows before finalizing groups
Order (Sort) Must collect all rows before sorting

DuckDB organizes operators into pipelines separated by these breakers.

Query: SELECT country, COUNT(*) FROM users WHERE age > 18 GROUP BY country ORDER BY count DESC LIMIT 10

Pipeline 1: TableScan(users) -> Filter(age>18) -> HashAggregate(GROUP BY country)
  -> Parallel: 4 threads scan different row groups, each builds partial hash table

Pipeline 2: HashAggregate (combine) -> Order(count DESC)
  -> Single-threaded: merge partial aggregates, sort

Pipeline 3: Order -> Limit(10)
  -> Early termination after 10 rows
Enter fullscreen mode Exit fullscreen mode

The Optimizer: Stratified and Subquery-Aware

Before execution, DuckDB runs a stratified optimizer — multiple passes, each with a specific goal:

  1. Logical rewrites — predicate pushdown, projection pushdown, join reordering, subquery unnesting
  2. Physical planning — choose operators, partition joins, decide parallelism
  3. Adaptive execution — runtime statistics feed back into filter ordering, join strategy

Predicate pushdown (logical)

-- Original
SELECT name FROM (SELECT * FROM users) WHERE age > 30;

-- Optimized: filter pushed into table scan
TableScan(users, FILTER age > 30) -> Project(name)
Enter fullscreen mode Exit fullscreen mode

DuckDB reads only the age and name columns. The other 48 columns never leave disk.

Projection pushdown

-- Only 'name' and 'age' columns read from storage
SELECT name FROM users WHERE age > 30;
Enter fullscreen mode Exit fullscreen mode

Columnar storage + projection pushdown = massive I/O reduction.

Join ordering

-- Small table (countries) as build side, large table (users) as probe side
SELECT * FROM users
JOIN countries ON users.country_id = countries.id;
Enter fullscreen mode Exit fullscreen mode

DuckDB estimates cardinalities and puts the smaller table on the build side automatically.


Adaptive Execution: Runtime Feedback

DuckDB doesn't just trust the optimizer. It adapts during execution.

Adaptive filtering

WHERE expensive_function(x) > 10 AND cheap_column = 'value'
Enter fullscreen mode Exit fullscreen mode

The executor tracks selectivity. If cheap_column = 'value' eliminates 99% of rows, it evaluates that first next time — even if the optimizer ordered it differently.

Adaptive hash joins

Data size Strategy
Small In-memory hash table
Medium Partitioned hash table (radix)
Large Disk-based partitioning with spillage

The same operator switches strategies mid-query based on actual row counts.


What This Means in Practice

Query Parquet files directly — no ETL

import duckdb

# Zero-copy: DuckDB reads Parquet column chunks directly
# No import, no copy, no schema inference step
result = duckdb.query("""
    SELECT station_id, AVG(temperature)
    FROM 's3://weather-data/2024/*.parquet'
    WHERE year = 2024
    GROUP BY station_id
""").df()
Enter fullscreen mode Exit fullscreen mode

The httpfs extension streams row groups. Predicate pushdown skips entire row groups via Parquet statistics.

Larger-than-memory analytics

DuckDB 0.9+ (Sep 2023) added out-of-core support for all operators:

  • Hash aggregate: spills to disk when groups exceed memory
  • Hash join: partitions build/probe sides to disk
  • Sort: external merge sort
  • Window functions: segment tree with disk spill
-- 50 GB data, 16 GB RAM — works
SELECT customer_id, COUNT(*)
FROM 's3://huge-dataset/*.parquet'
GROUP BY customer_id;
Enter fullscreen mode Exit fullscreen mode

When NOT to Use DuckDB

Use case Better choice
High-concurrency OLTP (many writers) PostgreSQL, MySQL
Mobile/embedded with tiny footprint SQLite
Real-time replication, read replicas PostgreSQL + streaming replication
Complex transactions, foreign keys PostgreSQL
Vector search (semantic similarity) pgvector, sqlite-vec, or dedicated vector DB

DuckDB is single-writer, multiple-reader. It's not a general-purpose RDBMS replacement.


Mental Model Summary

Concept What it means for you
Vector (2048 rows) CPU processes batches, not rows. SIMD, cache-friendly.
DataChunk Columns travel together. Filter = reduce count, not delete memory.
Push-based Data flows downhill. Pipelines fuse operators. No iterator overhead.
Morsel ~122K rows. Unit of parallelism. Threads steal morsels dynamically.
Pipeline breaker HashJoin build, HashAggregate, Sort. Synchronization points.
Adaptive Runtime stats override static plans. Filter reordering, join spill.

Peek Under the Hood Yourself

-- See the physical plan with parallelism
EXPLAIN ANALYZE
SELECT country, COUNT(*)
FROM users
WHERE age > 18
GROUP BY country;

-- Enable detailed profiling
PRAGMA enable_profiling;
PRAGMA profiling_output = 'profile.json';
SELECT ...;
-- Open profile.json: operator timings, memory, thread counts, actual vs estimated rows
Enter fullscreen mode Exit fullscreen mode

The profile shows exactly which pipelines ran in parallel, where data spilled, and whether the optimizer's estimates matched reality.


The Takeaway

DuckDB isn't "SQLite for analytics" because of its API. It's "SQLite for analytics" because it threw away the Volcano iterator model and built an execution engine from first principles for analytical workloads:

  • Columnar storage -> read only what you need
  • Vectorized (2048) -> SIMD, cache, amortized overhead
  • Push-based -> fused pipelines, no materialization
  • Morsel-driven -> self-parallelizing operators, no central bottleneck
  • Adaptive -> runtime reality beats static estimates

Next time you run a query and get results in milliseconds on a 10 GB Parquet file, you'll know exactly which thread processed which morsel, which pipeline it flowed through, and why it didn't need a server.

That's not magic. That's mechanical sympathy.

Top comments (0)