Columnar Data, from the inside out Query engines overview →

An answer is a plan.

SELECT d.observation_id, s.description, d.temperature_c FROM data d JOIN stations s USING (station) WHERE d.temperature_c >= 18 ORDER BY d.observation_id
DuckDB 1.4.3
PROJECTIONORDER_BYPROJECTIONPROJECTIONHASH_JOINPARQUET_SCANCOLUMN_DATA_SCAN
DataFusion 54.0.0
SortPreservingMergeExecSortExecHashJoinExecProjectionExecDataSourceExecFilterExecRepartitionExecDataSourceExec

Start with the artefact

One question. Two plans.

These recorded trees answer the same join query. Begin at the source and compare the operators each engine chose.

What a query engine is

A query engine turns SQL into a plan and executes that plan over data. Parquet supplies stored columns; the engine decides how to scan, join, order and return their values. Two engines can agree on every result while choosing different operators. This guide compares DuckDB with Apache DataFusion, a query framework used by applications building analytical systems. Their recorded plans show where the same question leads to different work.

ParquetColumns stored as bytesIceberg · Delta LakeFiles selected by a versionArrowTyped arrays in memoryDuckDB · DataFusionOperators turn values into answers
Table metadata selects files. Readers decode stored columns into arrays. Engines compute on those arrays.
About this fixture

Recorded with DuckDB 1.4.3 and Apache DataFusion 54.0.0. Both engines checked the same results. Diagrams evaluate relational operations over fixture rows; native plans and counters retain their capture context. Timing and memory experiments describe those runs, not a ranking.

Following 14 from the diagram

The same answer, two plans.

Start with the join query in the opening. Both engines return the same observation IDs and station descriptions. Their physical operators divide that work differently: one sort can become partition-local sorts and a merge.

Compare the raw plans below, then change the query. Use the DuckDB walkthrough to follow a single engine from SQL to result. Here, each experiment asks whether a different plan still computes the same answer.

Compare the actual plans.

Query

SELECT d.observation_id, s.description, d.temperature_c FROM data d JOIN stations s USING (station) WHERE d.temperature_c >= 18 ORDER BY d.observation_id

Both engines produced these 5 rows. These exact values were checked in both native executions.

observation_iddescriptiontemperature_c
14Southern site18.0
15Northern site19.0
16Southern site20.0
17Northern site21.0
18Southern site22.0

DuckDB · physical EXPLAIN

┌───────────────────────────┐
│         PROJECTION        │
│    ────────────────────   │
│__internal_decompress_integ│
│     ral_bigint(#0, 1)     │
│             #1            │
│             #2            │
│                           │
│          ~0 rows          │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│          ORDER_BY         │
│    ────────────────────   │
│    d.observation_id ASC   │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│         PROJECTION        │
│    ────────────────────   │
│__internal_compress_integra│
│     l_utinyint(#0, 1)     │
│             #1            │
│             #2            │
│                           │
│          ~3 rows          │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│         PROJECTION        │
│    ────────────────────   │
│       observation_id      │
│        description        │
│       temperature_c       │
│                           │
│          ~3 rows          │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│         HASH_JOIN         │
│    ────────────────────   │
│      Join Type: INNER     │
│                           │
│        Conditions:        ├──────────────┐
│     station = station     │              │
│                           │              │
│          ~3 rows          │              │
└─────────────┬─────────────┘              │
┌─────────────┴─────────────┐┌─────────────┴─────────────┐
│       PARQUET_SCAN        ││      COLUMN_DATA_SCAN     │
│    ────────────────────   ││    ────────────────────   │
│         Function:         ││                           │
│        PARQUET_SCAN       ││                           │
│                           ││                           │
│        Projections:       ││                           │
│       observation_id      ││                           │
│          station          ││                           │
│       temperature_c       ││                           │
│                           ││                           │
│          Filters:         ││                           │
│    temperature_c>=18.0    ││                           │
│                           ││                           │
│          ~3 rows          ││          ~2 rows          │
└───────────────────────────┘└───────────────────────────┘

DataFusion · recorded plan

Plan stage

SortPreservingMergeExec: [observation_id@0 ASC NULLS LAST]
  SortExec: expr=[observation_id@0 ASC NULLS LAST], preserve_partitioning=[true]
    HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(CAST(s.station AS Utf8View)@2, station@1)], projection=[observation_id@3, description@1, temperature_c@5]
      ProjectionExec: expr=[station@0 as station, description@1 as description, CAST(station@0 AS Utf8View) as CAST(s.station AS Utf8View)]
        DataSourceExec: partitions=1, partition_sizes=[1]
      FilterExec: temperature_c@2 >= 18
        RepartitionExec: partitioning=RoundRobinBatch(2), input_partitions=1
          DataSourceExec: file_groups={1 group: [[<fixture>/weather.parquet]]}, projection=[observation_id, station, temperature_c], file_type=parquet, predicate=temperature_c@3 >= 18 AND DynamicFilter [ empty ], pruning_predicate=temperature_c_null_count@1 != row_count@2 AND temperature_c_max@0 >= 18, required_guarantees=[]

Only the absolute fixture file path is replaced by <fixture>/weather.parquet. No operators or estimates are removed. Internal casts, repartitions and unknown operators remain visible. The raw captures are downloadable below.

This opens the live DuckDB browser tool. The DataFusion captures come from the pinned native reproduction script.

Same question, less intermediate work

Why attach a description to a row we will discard?

We want warm observations, their station descriptions, and increasing observation IDs. The timestamp is irrelevant to this answer. Keep this query in view as its implementation changes:

SELECT d.observation_id, s.description, d.temperature_c
FROM data d
JOIN stations s USING (station)
WHERE d.temperature_c >= 18
ORDER BY d.observation_id

The unoptimized logical plan places the temperature filter above the inner join. Because that predicate depends only on the weather input, the optimizer can move it below the join. Switch between these equivalent relational orders.

Weather input18 observationsid · station · temperature
temperature ≥ 1818 → 5 rows
Inner join5 weather rows probe the lookup
← North / South
2 retained keys
Order by observation ID14 · 15 · 16 · 17 · 18

ID 14: reaches the join; survives into the ordered result.

5 intermediate joined rows, five final rows. Moving the filter changes how many fixture rows reach the join. DataFusion’s recorded optimized plan chooses the earlier filter.

What makes the rewrite legal?

Every surviving weather row finds the same station matches in either order. The predicate reads no lookup columns and has no side effects. A predicate on the description could not be evaluated from weather alone. Outer joins and predicates involving nulls require their own equivalence checks.

Projection moves too: observed_at is dropped from the scan’s requested columns. station must stay until the join, even though the final output only contains its description. Avoiding a column and rejecting rows are different decisions.

Locate this rewrite in the captured DataFusion plans

Before optimization

Sort: d.observation_id ASC NULLS LAST
  Projection: d.observation_id, s.description, d.temperature_c
    Filter: d.temperature_c >= Int64(18)
      Inner Join: Using d.station = s.station
        SubqueryAlias: d
          TableScan: data
        SubqueryAlias: s
          TableScan: stations

After optimization

Sort: d.observation_id ASC NULLS LAST
  Projection: d.observation_id, s.description, d.temperature_c
    Inner Join: d.station = CAST(s.station AS Utf8View)
      SubqueryAlias: d
        Filter: data.temperature_c >= Float64(18)
          TableScan: data projection=[observation_id, station, temperature_c], partial_filters=[data.temperature_c >= Float64(18)]
      SubqueryAlias: s
        TableScan: stations projection=[station, description]

Next, implement the optimizer’s recorded choice: filter before the join. The same five warm rows become probes; the two station descriptions become retained state.

Build and probe, step by step.

The DuckDB guide follows every build and probe. Here we compare what changes when an optimizer chooses a different order.

Change the assumption

What did the two-key weather lookup hide?

Keep the build/probe algorithm, but introduce collisions, duplicate keys and nulls. These edge cases test the rules just used for the weather answer; return to that answer for ordering below.

A hash locates candidates. Equality decides matches.

The station join above has unique keys. To expose the machinery it hides, use this small edge-case fixture: keys 1, 5 and a second 1, plus a null key. Probe it with 1, 5, 9 and null. Native DuckDB records both inner and left-join answers.

Illustrative bucket count

Probe key

Teaching hash: bucket = key mod 4. Real engines use stronger hashes and different table layouts; the equality and multiplicity obligations remain.

Bucket 0
empty
Bucket 1
1 → North5 → West1 → North alternate
Bucket 2
empty
Bucket 3
empty

For p.key = b.key, a null comparison produces unknown. The join requires true, so it excludes the null build key.

Candidate comparisons

1 = 1: match

1 = 5: collision, reject

1 = 1: match

Emitted rows

ID 1 · key 1 · North

ID 1 · key 1 · North alternate

3 candidate entries inspected; 2 equal-key matches; 2 output rows.

Why does changing bucket count not change the answer?

Keys 1 and 5 collide with four buckets but separate with eight. Key 1 still matches two build entries in both cases. Hashing changes the candidate search, while equality and duplicate handling preserve SQL semantics. A hash-only comparison would return the wrong descriptions for key 9.

The same-looking filter move can change an outer join.

The earlier weather-only filter could move below an inner join. Now filter the nullable right side of a left join. A WHERE predicate runs after unmatched left rows have been extended with nulls. An ON predicate controls matching before that extension.

Where does description = North live?

SELECT p.id, b.value
FROM probe p LEFT JOIN build b ON p.key = b.key
WHERE b.value = 'North'
ID 1 → North

WHERE discards null-extended rows because NULL = North is unknown. Only the matching North row survives.

The native results contain 1 rows. Moving this predicate from WHERE to ON changes the answer. An optimizer can use other legal transformations, including converting a null-rejecting outer join to an inner join, but it must prove the resulting semantics.

A pipeline is constrained by what each operator must know.

The captured hash join completes its build before probing, so each probe can find every matching build row. The second key-1 description might arrive last. This creates a dependency even when scan and expression work can run in batches.

Build input: 0/4 consumed

1 → North5 → West1 → North alternateNULL → Unknown

Build pipeline is open. Retained state is incomplete.

Probe input: 0/4 consumed

1: 12: 53: 94: NULL

Waiting for the build barrier

Keeping four build records is a different memory requirement from buffering every probe record. A large build can require partitioning or spilling. The number of rows alone is insufficient to estimate memory: key width, retained payload, table capacity and duplicate chains also matter.

Partitioning can distribute work and still leave a hot key.

Route twelve non-null probe keys through the same illustrative modulo hash. Assume one worker per partition and one unit of work per row, with no transfer or scheduling overhead. The longest queue is a lower bound on completion under those assumptions.

Partition workers

Key distribution

Worker 0
4
Worker 1
111111115
Worker 2
2
Worker 3
3

9 work units in the longest queue; ideal perfectly divided load 3. At most 1.33× speedup over twelve serial units in this model.

All eight copies of key 1 reach the same worker under this routing rule. Equal keys must meet their equality candidates; changing to round-robin alone breaks that guarantee. A different join strategy can share or replicate a small build. DataFusion’s recorded CollectLeft join above is one such choice. The queues expose work distribution. Payload size, duplicate matches, memory and exchange costs also influence execution.

Return to the five weather results

The join found the rows. It did not order them.

First establish order within an input; then merge the sorted streams. The merge sends its emitted prefix into the live Arrow output below.

Keeping the smallest three still requires seeing the last row.

The running query has ORDER BY and no LIMIT, so it must retain enough information to order all five results. A related ORDER BY … LIMIT 3 query can keep only three best candidates. It must wait for input to end: the next row might be smaller than every retained row.

Illustrative incoming order

1814171516

Retained ordered state

empty

0 retained IDs. Still provisional: smaller unseen values can change it.

With a bounded top-three state, receiving 15 evicts 18; receiving 16 evicts 17. A heap is one way to maintain that bound. Full sorting may create sorted runs and merge them, spilling runs when memory is constrained. The merge below starts after each input has established its local order.

An execution plan therefore carries more than a list of verbs: it chooses algorithms, retained state, ordering guarantees and dependencies. Two engines can return identical rows with different choices. Their captured plans establish those choices; timings, spill volumes and speed rankings require separate measurements.

Fulfil the ORDER BY promise

Local order is not global order.

DataFusion’s plan contains SortExec with preserved partitioning, followed by SortPreservingMergeExec. Each stream can be sorted while their concatenation is still out of order. The two streams below are a teaching partition of those five joined rows.

0 of five recorded rows loaded. Load the complete join result before merging.

Sorted stream 1

141618

Head: 14

Sorted stream 2

1517

Head: 15

Ordered input streamsstream 1 · ID 14stream 1 · ID 16stream 1 · ID 18stream 2 · ID 15stream 2 · ID 17Global output∅ No rows
Each emitted identity comes from one of the two ordered streams. Lines follow the same identity between columns.

Global order: nothing emitted

The merge compares available heads, emits the smaller ID, and advances only that stream. Concatenation would produce 14, 16, 18, 15, 17. Merging produces the SQL order, 14, 15, 16, 17, 18. Equal sort keys need no particular relative order unless the SQL specifies a tie-breaker.

A filter can decide one row at a time. An unsorted input needs sorting before this merge can consume ordered streams. Hash aggregation retains counts and sums per group; a join retains build keys. These state requirements explain why simply drawing every operator as a streaming arrow hides useful differences.

Ten returned rows can still require a million input rows.

This separate experiment uses one million synthetic events and a 500,000-row lookup. DuckDB 1.4.4 executes five queries at two memory settings; Polars 1.44.1 / PyArrow 25.0.1 independently verifies every successful result in full. These are recorded native runs, not browser benchmarks or playback timings.

Each run starts a fresh connection with one DuckDB thread, insertion-order preservation off and at most 1 GB of temporary disk space. The buffer setting is written in decimal MB; measurements below use MiB. Whole-process resident memory also includes allocations outside that managed budget.

The wide aggregation failed at 48 MB. The lower-cardinality aggregation completed. The join and full sort used temporary disk space at the lower limit. Top-10 retained less sorting state and did not spill, but its profile still reports all one million event rows scanned.

QueryBuffer settingOutcomeReturned rowsReported rows scannedPeak buffered MiBPeak temp MiB
500,000 aggregate groups48MBOut of memoryNo answerNot capturedNot capturedNot captured
500,000 aggregate groups256MBCompleted500,0001,000,00028.310.00
4,096 aggregate groups48MBCompleted4,0961,000,0003.310.00
4,096 aggregate groups256MBCompleted4,0961,000,0003.310.00
Wide hash join48MBCompleted11,500,00045.7781.44
Wide hash join256MBCompleted11,500,000102.910.00
Full string sort48MBCompleted1,000,0001,000,00045.6166.28
Full string sort256MBCompleted1,000,0001,000,000132.390.00
Same order, LIMIT 1048MBCompleted101,000,00030.570.00
Same order, LIMIT 10256MBCompleted101,000,00030.570.00

The join scans both inputs, so its reported total is 1.5 million rows. Scanned-row counters count rows examined at the inputs. The tables distinguish output cardinality from scanned rows. Parent and child operators can count the same records at different stages.

The successful 256 MB aggregation’s reported peak is below 48 MB, yet the 48 MB run failed. The limit affects execution and allocation decisions; reducing the cap can change the allocations needed to finish.

500,000 aggregate groups · 48MB · out of memory
SELECT key, sum(id)::BIGINT AS total, count(*)::BIGINT AS n FROM events GROUP BY key ORDER BY key

The native execution did not produce a result. This failure is retained as evidence.

Out of Memory Error: could not allocate block of size 256.0 KiB (45.5 MiB/45.7 MiB used)

Possible solutions:
* Reducing the number of threads (SET threads=X)
* Disabling insertion-order preservation (SET preserve_insertion_order=false)
* Increasing the memory limit (SET memory_limit='...GB')

See also https://duckdb.org/docs/stable/guides/performance/how_to_tune_workloads
500,000 aggregate groups · 256MB · completed
SELECT key, sum(id)::BIGINT AS total, count(*)::BIGINT AS n FROM events GROUP BY key ORDER BY key

One captured execution: 0.043 s latency; 0.038 s reported CPU time. Use the pinned recipe to observe the same query under your own conditions.

Plan positionNative operatorEstimated cardinalityOutput rowsOwn rows scanned
0.0PROJECTION535387500,0000
0.0.0ORDER_BYNot reported500,0000
0.0.0.0PROJECTION535387500,0000
0.0.0.0.0PROJECTION535387500,0000
0.0.0.0.0.0PROJECTION535387500,0000
0.0.0.0.0.0.0HASH_GROUP_BY535387500,0000
0.0.0.0.0.0.0.0PROJECTION10000001,000,0000
0.0.0.0.0.0.0.0.0PROJECTION10000001,000,0000
0.0.0.0.0.0.0.0.0.0SEQ_SCAN 10000001,000,0001,000,000

First 8 of 500,000 exact result rows:

[
  {
    "key": 0,
    "total": 500000,
    "n": 2
  },
  {
    "key": 1,
    "total": 500002,
    "n": 2
  },
  {
    "key": 2,
    "total": 500004,
    "n": 2
  },
  {
    "key": 3,
    "total": 500006,
    "n": 2
  },
  {
    "key": 4,
    "total": 500008,
    "n": 2
  },
  {
    "key": 5,
    "total": 500010,
    "n": 2
  },
  {
    "key": 6,
    "total": 500012,
    "n": 2
  },
  {
    "key": 7,
    "total": 500014,
    "n": 2
  }
]

Last 8 result rows:

[
  {
    "key": 499992,
    "total": 1499984,
    "n": 2
  },
  {
    "key": 499993,
    "total": 1499986,
    "n": 2
  },
  {
    "key": 499994,
    "total": 1499988,
    "n": 2
  },
  {
    "key": 499995,
    "total": 1499990,
    "n": 2
  },
  {
    "key": 499996,
    "total": 1499992,
    "n": 2
  },
  {
    "key": 499997,
    "total": 1499994,
    "n": 2
  },
  {
    "key": 499998,
    "total": 1499996,
    "n": 2
  },
  {
    "key": 499999,
    "total": 1499998,
    "n": 2
  }
]

Complete result SHA-256: ca7c525c513a26670afd0845708f3e2956b5012fc4eb61e07f55120dc9bd30f3

The digest covers every row in a canonical Arrow IPC stream with int64 columns. The verifier also compares the full native and Polars tables before computing it. Download the original normalized native profile.

4,096 aggregate groups · 48MB · completed
SELECT key % 4096 AS bucket, sum(id)::BIGINT AS total, count(*)::BIGINT AS n FROM events GROUP BY bucket ORDER BY bucket

One captured execution: 0.013 s latency; 0.012 s reported CPU time. Use the pinned recipe to observe the same query under your own conditions.

Plan positionNative operatorEstimated cardinalityOutput rowsOwn rows scanned
0.0ORDER_BYNot reported4,0960
0.0.0PROJECTION5000004,0960
0.0.0.0HASH_GROUP_BY5000004,0960
0.0.0.0.0PROJECTION10000001,000,0000
0.0.0.0.0.0SEQ_SCAN 10000001,000,0001,000,000

First 8 of 4,096 exact result rows:

[
  {
    "bucket": 0,
    "total": 122964576,
    "n": 246
  },
  {
    "bucket": 1,
    "total": 122964822,
    "n": 246
  },
  {
    "bucket": 2,
    "total": 122965068,
    "n": 246
  },
  {
    "bucket": 3,
    "total": 122965314,
    "n": 246
  },
  {
    "bucket": 4,
    "total": 122965560,
    "n": 246
  },
  {
    "bucket": 5,
    "total": 122965806,
    "n": 246
  },
  {
    "bucket": 6,
    "total": 122966052,
    "n": 246
  },
  {
    "bucket": 7,
    "total": 122966298,
    "n": 246
  }
]

Last 8 result rows:

[
  {
    "bucket": 4088,
    "total": 122462624,
    "n": 244
  },
  {
    "bucket": 4089,
    "total": 122462868,
    "n": 244
  },
  {
    "bucket": 4090,
    "total": 122463112,
    "n": 244
  },
  {
    "bucket": 4091,
    "total": 122463356,
    "n": 244
  },
  {
    "bucket": 4092,
    "total": 122463600,
    "n": 244
  },
  {
    "bucket": 4093,
    "total": 122463844,
    "n": 244
  },
  {
    "bucket": 4094,
    "total": 122464088,
    "n": 244
  },
  {
    "bucket": 4095,
    "total": 122464332,
    "n": 244
  }
]

Complete result SHA-256: e7b52d37fa6cef35712896f0c03e2e3e38965012400d34ad489cad02345766b3

The digest covers every row in a canonical Arrow IPC stream with int64 columns. The verifier also compares the full native and Polars tables before computing it. Download the original normalized native profile.

4,096 aggregate groups · 256MB · completed
SELECT key % 4096 AS bucket, sum(id)::BIGINT AS total, count(*)::BIGINT AS n FROM events GROUP BY bucket ORDER BY bucket

One captured execution: 0.012 s latency; 0.012 s reported CPU time. Use the pinned recipe to observe the same query under your own conditions.

Plan positionNative operatorEstimated cardinalityOutput rowsOwn rows scanned
0.0ORDER_BYNot reported4,0960
0.0.0PROJECTION5000004,0960
0.0.0.0HASH_GROUP_BY5000004,0960
0.0.0.0.0PROJECTION10000001,000,0000
0.0.0.0.0.0SEQ_SCAN 10000001,000,0001,000,000

First 8 of 4,096 exact result rows:

[
  {
    "bucket": 0,
    "total": 122964576,
    "n": 246
  },
  {
    "bucket": 1,
    "total": 122964822,
    "n": 246
  },
  {
    "bucket": 2,
    "total": 122965068,
    "n": 246
  },
  {
    "bucket": 3,
    "total": 122965314,
    "n": 246
  },
  {
    "bucket": 4,
    "total": 122965560,
    "n": 246
  },
  {
    "bucket": 5,
    "total": 122965806,
    "n": 246
  },
  {
    "bucket": 6,
    "total": 122966052,
    "n": 246
  },
  {
    "bucket": 7,
    "total": 122966298,
    "n": 246
  }
]

Last 8 result rows:

[
  {
    "bucket": 4088,
    "total": 122462624,
    "n": 244
  },
  {
    "bucket": 4089,
    "total": 122462868,
    "n": 244
  },
  {
    "bucket": 4090,
    "total": 122463112,
    "n": 244
  },
  {
    "bucket": 4091,
    "total": 122463356,
    "n": 244
  },
  {
    "bucket": 4092,
    "total": 122463600,
    "n": 244
  },
  {
    "bucket": 4093,
    "total": 122463844,
    "n": 244
  },
  {
    "bucket": 4094,
    "total": 122464088,
    "n": 244
  },
  {
    "bucket": 4095,
    "total": 122464332,
    "n": 244
  }
]

Complete result SHA-256: e7b52d37fa6cef35712896f0c03e2e3e38965012400d34ad489cad02345766b3

The digest covers every row in a canonical Arrow IPC stream with int64 columns. The verifier also compares the full native and Polars tables before computing it. Download the original normalized native profile.

Wide hash join · 48MB · completed
SELECT sum(length(e.payload || d.payload))::BIGINT AS checksum, count(*)::BIGINT AS n FROM events e JOIN lookup d USING (key)

One captured execution: 0.309 s latency; 0.304 s reported CPU time. Use the pinned recipe to observe the same query under your own conditions.

Plan positionNative operatorEstimated cardinalityOutput rowsOwn rows scanned
0.0PROJECTION110
0.0.0UNGROUPED_AGGREGATENot reported10
0.0.0.0PROJECTION7066461,000,0000
0.0.0.0.0HASH_JOIN7066461,000,0000
0.0.0.0.0.0SEQ_SCAN 10000001,000,0001,000,000
0.0.0.0.0.1SEQ_SCAN 500000500,000500,000

First 1 of 1 exact result rows:

[
  {
    "checksum": 128000000,
    "n": 1000000
  }
]

Complete result SHA-256: 5880d049dc5289f1b20cd60ae7197f0c4be6f829098fef406d0675c06735644c

The digest covers every row in a canonical Arrow IPC stream with int64 columns. The verifier also compares the full native and Polars tables before computing it. Download the original normalized native profile.

Wide hash join · 256MB · completed
SELECT sum(length(e.payload || d.payload))::BIGINT AS checksum, count(*)::BIGINT AS n FROM events e JOIN lookup d USING (key)

One captured execution: 0.117 s latency; 0.112 s reported CPU time. Use the pinned recipe to observe the same query under your own conditions.

Plan positionNative operatorEstimated cardinalityOutput rowsOwn rows scanned
0.0PROJECTION110
0.0.0UNGROUPED_AGGREGATENot reported10
0.0.0.0PROJECTION7066461,000,0000
0.0.0.0.0HASH_JOIN7066461,000,0000
0.0.0.0.0.0SEQ_SCAN 10000001,000,0001,000,000
0.0.0.0.0.1SEQ_SCAN 500000500,000500,000

First 1 of 1 exact result rows:

[
  {
    "checksum": 128000000,
    "n": 1000000
  }
]

Complete result SHA-256: 5880d049dc5289f1b20cd60ae7197f0c4be6f829098fef406d0675c06735644c

The digest covers every row in a canonical Arrow IPC stream with int64 columns. The verifier also compares the full native and Polars tables before computing it. Download the original normalized native profile.

Full string sort · 48MB · completed
SELECT id FROM events ORDER BY payload, id

One captured execution: 0.350 s latency; 0.316 s reported CPU time. Use the pinned recipe to observe the same query under your own conditions.

Plan positionNative operatorEstimated cardinalityOutput rowsOwn rows scanned
0.0PROJECTION10000001,000,0000
0.0.0ORDER_BYNot reported1,000,0000
0.0.0.0PROJECTION10000001,000,0000
0.0.0.0.0SEQ_SCAN 10000001,000,0001,000,000

First 8 of 1,000,000 exact result rows:

[
  {
    "id": 848775
  },
  {
    "id": 752491
  },
  {
    "id": 738639
  },
  {
    "id": 5329
  },
  {
    "id": 79042
  },
  {
    "id": 241361
  },
  {
    "id": 552871
  },
  {
    "id": 646434
  }
]

Last 8 result rows:

[
  {
    "id": 789215
  },
  {
    "id": 986128
  },
  {
    "id": 616604
  },
  {
    "id": 233443
  },
  {
    "id": 373470
  },
  {
    "id": 95102
  },
  {
    "id": 803622
  },
  {
    "id": 40691
  }
]

Complete result SHA-256: fafa1331f2fe3437c994c667494b36e7464fdc57f612804df25fd253fb1ae202

The digest covers every row in a canonical Arrow IPC stream with int64 columns. The verifier also compares the full native and Polars tables before computing it. Download the original normalized native profile.

Full string sort · 256MB · completed
SELECT id FROM events ORDER BY payload, id

One captured execution: 0.183 s latency; 0.127 s reported CPU time. Use the pinned recipe to observe the same query under your own conditions.

Plan positionNative operatorEstimated cardinalityOutput rowsOwn rows scanned
0.0PROJECTION10000001,000,0000
0.0.0ORDER_BYNot reported1,000,0000
0.0.0.0PROJECTION10000001,000,0000
0.0.0.0.0SEQ_SCAN 10000001,000,0001,000,000

First 8 of 1,000,000 exact result rows:

[
  {
    "id": 848775
  },
  {
    "id": 752491
  },
  {
    "id": 738639
  },
  {
    "id": 5329
  },
  {
    "id": 79042
  },
  {
    "id": 241361
  },
  {
    "id": 552871
  },
  {
    "id": 646434
  }
]

Last 8 result rows:

[
  {
    "id": 789215
  },
  {
    "id": 986128
  },
  {
    "id": 616604
  },
  {
    "id": 233443
  },
  {
    "id": 373470
  },
  {
    "id": 95102
  },
  {
    "id": 803622
  },
  {
    "id": 40691
  }
]

Complete result SHA-256: fafa1331f2fe3437c994c667494b36e7464fdc57f612804df25fd253fb1ae202

The digest covers every row in a canonical Arrow IPC stream with int64 columns. The verifier also compares the full native and Polars tables before computing it. Download the original normalized native profile.

Same order, LIMIT 10 · 48MB · completed
SELECT id FROM events ORDER BY payload, id LIMIT 10

One captured execution: 0.033 s latency; 0.033 s reported CPU time. Use the pinned recipe to observe the same query under your own conditions.

Plan positionNative operatorEstimated cardinalityOutput rowsOwn rows scanned
0.0PROJECTION10100
0.0.0TOP_NNot reported100
0.0.0.0SEQ_SCAN 1000000860,1601,000,000

First 8 of 10 exact result rows:

[
  {
    "id": 848775
  },
  {
    "id": 752491
  },
  {
    "id": 738639
  },
  {
    "id": 5329
  },
  {
    "id": 79042
  },
  {
    "id": 241361
  },
  {
    "id": 552871
  },
  {
    "id": 646434
  }
]

Last 8 result rows:

[
  {
    "id": 738639
  },
  {
    "id": 5329
  },
  {
    "id": 79042
  },
  {
    "id": 241361
  },
  {
    "id": 552871
  },
  {
    "id": 646434
  },
  {
    "id": 414859
  },
  {
    "id": 194813
  }
]

Complete result SHA-256: c1196b1878223b1b92d24ae9c5f8537e3c42602d4a3699dc6115a3901291b063

The digest covers every row in a canonical Arrow IPC stream with int64 columns. The verifier also compares the full native and Polars tables before computing it. Download the original normalized native profile.

Same order, LIMIT 10 · 256MB · completed
SELECT id FROM events ORDER BY payload, id LIMIT 10

One captured execution: 0.031 s latency; 0.031 s reported CPU time. Use the pinned recipe to observe the same query under your own conditions.

Plan positionNative operatorEstimated cardinalityOutput rowsOwn rows scanned
0.0PROJECTION10100
0.0.0TOP_NNot reported100
0.0.0.0SEQ_SCAN 1000000860,1601,000,000

First 8 of 10 exact result rows:

[
  {
    "id": 848775
  },
  {
    "id": 752491
  },
  {
    "id": 738639
  },
  {
    "id": 5329
  },
  {
    "id": 79042
  },
  {
    "id": 241361
  },
  {
    "id": 552871
  },
  {
    "id": 646434
  }
]

Last 8 result rows:

[
  {
    "id": 738639
  },
  {
    "id": 5329
  },
  {
    "id": 79042
  },
  {
    "id": 241361
  },
  {
    "id": 552871
  },
  {
    "id": 646434
  },
  {
    "id": 414859
  },
  {
    "id": 194813
  }
]

Complete result SHA-256: c1196b1878223b1b92d24ae9c5f8537e3c42602d4a3699dc6115a3901291b063

The digest covers every row in a canonical Arrow IPC stream with int64 columns. The verifier also compares the full native and Polars tables before computing it. Download the original normalized native profile.

Reproduce the inputs, outputs and profile evidence

Captured 2026-09-12T17:10:41.932640+00:00 on Darwin / arm64. Complete capture, input hashes and oracle results · Pinned reproduction script.

Run uv run reproduce.py --output new-capture in a directory of your choice. The script regenerates both input tables in a temporary workspace, writes deterministic Parquet inputs, verifies them with an independent reader, and records fresh profiles. The reproduction script generates its inputs locally. This page presents the recorded result.

{
  "events": {
    "rows": 1000000,
    "sql": "SELECT i AS id, i % 500000 AS key, (i * 104729) % 1000000 AS score, repeat(md5(i::VARCHAR), 2) AS payload FROM range(1000000) t(i)",
    "parquetBytes": 50319922,
    "parquetSha256": "10ed782b8fced6891ce674a4f826b6caf345b4e5c851ea5b7b2a09c3adad9604"
  },
  "lookup": {
    "rows": 500000,
    "sql": "SELECT i AS key, i * 3 AS amount, repeat(md5(i::VARCHAR), 2) AS payload FROM range(500000) t(i)",
    "parquetBytes": 22663755,
    "parquetSha256": "4eab6c07075e5a141fa5745aea5bd8c25a72357cce4b48b646646cdeb08c5d1d"
  }
}

Only the temporary workspace path is replaced. Timings and native metrics are retained. Result digests are canonical uncompressed Arrow IPC streams with int64 columns and batches of 65536 rows.

DuckDB metric definitions · Blocking operators and spill limits. Behavior outside the pinned engine, queries and configuration requires a new capture.

Challenge the estimate. Check the answer.

DuckDB 1.4.4 runs twelve small native experiments. Polars 1.44.1 / PyArrow 25.0.1 checks the complete result of every query and the generated inputs independently. Select a captured execution to compare its plan, estimates and measured result.

Optimization counterexample

6 optimization comparisons shown.

Remove the statistics, keep the records.

Both uncompressed Parquet files contain the same 100,000 sorted records in 25 row groups. One stores column bounds; the other omits them. The query asks for ID 43.

Both answers are 731. Bounds rule out 24 groups in the original file. The native byte counter increases without statistics, while both scan operators report 100,000 rows scanned. Inspect the source and pruning counters separately to understand the work skipped.

Inspect 2 recorded results and plans

stats

SELECT sum(value)::BIGINT AS total FROM read_parquet('stats.parquet') WHERE id=43

Complete result:

[
  {
    "total": 731
  }
]

Native total_bytes_read: 7,416. Interpret this as the engine’s reported byte counter. An in-memory input still requires CPU and memory work.

PositionNative operatorEstimateOutput rowsOwn rows scannedNative details
0.0PROJECTION110
{
  "Projections": "total",
  "Estimated Cardinality": "1"
}
0.0.0UNGROUPED_AGGREGATENot reported10
{
  "Aggregates": "sum(#0)"
}
0.0.0.0PROJECTION2000010
{
  "Projections": "value",
  "Estimated Cardinality": "20000"
}
0.0.0.0.0READ_PARQUET 200001100,000
{
  "Function": "READ_PARQUET",
  "Projections": "value",
  "Filters": "id=43",
  "Estimated Cardinality": "20000",
  "Total Files Read": "1"
}

Download stats native profile · Complete result SHA-256: 71e1596bf552a2397b10933234988e08020a02b697cfa719d10751963764afe0

missing

SELECT sum(value)::BIGINT AS total FROM read_parquet('missing.parquet') WHERE id=43

Complete result:

[
  {
    "total": 731
  }
]

Native total_bytes_read: 17,404. Interpret this as the engine’s reported byte counter. An in-memory input still requires CPU and memory work.

PositionNative operatorEstimateOutput rowsOwn rows scannedNative details
0.0PROJECTION110
{
  "Projections": "total",
  "Estimated Cardinality": "1"
}
0.0.0UNGROUPED_AGGREGATENot reported10
{
  "Aggregates": "sum(#0)"
}
0.0.0.0PROJECTION2000010
{
  "Projections": "value",
  "Estimated Cardinality": "20000"
}
0.0.0.0.0READ_PARQUET 200001100,000
{
  "Function": "READ_PARQUET",
  "Projections": "value",
  "Filters": "id=43",
  "Estimated Cardinality": "20000",
  "Total Files Read": "1"
}

Download missing native profile · Complete result SHA-256: 71e1596bf552a2397b10933234988e08020a02b697cfa719d10751963764afe0

A pushed filter is not always a usable range.

Compare integer equality with equality after casting the same ID to text. The fixture has nonnegative integer IDs, so these particular predicates select exactly the same row.

Both predicates appear inside READ_PARQUET. The cast case reports more bytes read. Compare the scan’s actual pruning information to see whether integer bounds still eliminate the same work. The predicate’s type conversion determines which bounds the optimizer can use.

Inspect 2 recorded results and plans

stats

SELECT sum(value)::BIGINT AS total FROM read_parquet('stats.parquet') WHERE id=43

Complete result:

[
  {
    "total": 731
  }
]

Native total_bytes_read: 7,416. Interpret this as the engine’s reported byte counter. An in-memory input still requires CPU and memory work.

PositionNative operatorEstimateOutput rowsOwn rows scannedNative details
0.0PROJECTION110
{
  "Projections": "total",
  "Estimated Cardinality": "1"
}
0.0.0UNGROUPED_AGGREGATENot reported10
{
  "Aggregates": "sum(#0)"
}
0.0.0.0PROJECTION2000010
{
  "Projections": "value",
  "Estimated Cardinality": "20000"
}
0.0.0.0.0READ_PARQUET 200001100,000
{
  "Function": "READ_PARQUET",
  "Projections": "value",
  "Filters": "id=43",
  "Estimated Cardinality": "20000",
  "Total Files Read": "1"
}

Download stats native profile · Complete result SHA-256: 71e1596bf552a2397b10933234988e08020a02b697cfa719d10751963764afe0

cast

SELECT sum(value)::BIGINT AS total FROM read_parquet('stats.parquet') WHERE CAST(id AS VARCHAR)='43'

Complete result:

[
  {
    "total": 731
  }
]

Native total_bytes_read: 19,704. Interpret this as the engine’s reported byte counter. An in-memory input still requires CPU and memory work.

PositionNative operatorEstimateOutput rowsOwn rows scannedNative details
0.0PROJECTION110
{
  "Projections": "total",
  "Estimated Cardinality": "1"
}
0.0.0UNGROUPED_AGGREGATENot reported10
{
  "Aggregates": "sum(#0)"
}
0.0.0.0PROJECTION2000010
{
  "Projections": "value",
  "Estimated Cardinality": "20000"
}
0.0.0.0.0READ_PARQUET 200001100,000
{
  "Function": "READ_PARQUET",
  "Projections": "value",
  "Filters": "(CAST(id AS VARCHAR) = '43')",
  "Estimated Cardinality": "20000",
  "Total Files Read": "1"
}

Download cast native profile · Complete result SHA-256: 71e1596bf552a2397b10933234988e08020a02b697cfa719d10751963764afe0

The same marginal distributions can hide a relationship.

Each column contains 0–99 exactly 100 times in both tables. The independent table contains every pair once; the correlated table has a=b in every row. Query a<10 AND b>=90.

The independent table returns count 100; the correlated table returns 0. Both scan estimates are 2,000. The joint distribution also depends on how column values occur together in rows.

Inspect 2 recorded results and plans

independent

SELECT count(*) AS n FROM independent WHERE a<10 AND b>=90

Complete result:

[
  {
    "n": 100
  }
]

Native total_bytes_read: 0. Interpret this as the engine’s reported byte counter. An in-memory input still requires CPU and memory work.

PositionNative operatorEstimateOutput rowsOwn rows scannedNative details
0.0UNGROUPED_AGGREGATENot reported10
{
  "Aggregates": "count_star()"
}
0.0.0SEQ_SCAN 200010010,000
{
  "Table": "independent",
  "Type": "Sequential Scan",
  "Projections": "",
  "Filters": [
    "a<10",
    "b>=90"
  ],
  "Estimated Cardinality": "2000"
}

Download independent native profile · Complete result SHA-256: 4b5975cfbafa512614c1808d5376af0a8fe15937048ab837d73b3f324686a9a4

correlated

SELECT count(*) AS n FROM correlated WHERE a<10 AND b>=90

Complete result:

[
  {
    "n": 0
  }
]

Native total_bytes_read: 0. Interpret this as the engine’s reported byte counter. An in-memory input still requires CPU and memory work.

PositionNative operatorEstimateOutput rowsOwn rows scannedNative details
0.0UNGROUPED_AGGREGATENot reported10
{
  "Aggregates": "count_star()"
}
0.0.0SEQ_SCAN 2000010,000
{
  "Table": "correlated",
  "Type": "Sequential Scan",
  "Projections": "",
  "Filters": [
    "a<10",
    "b>=90"
  ],
  "Estimated Cardinality": "2000"
}

Download correlated native profile · Complete result SHA-256: 9edf26a11e8aad8749dabfe0f58c5b1739d2cfb434fa6b6af0e30777f1314189

Distinct keys do not describe their frequencies.

Both tables have 10,000 rows and keys 0–99. The uniform table repeats each key 100 times. In the skewed table, the first 9,500 rows use key 0; five later rows also use it.

Filtering key=0 produces 100 versus 9,505 rows. Inspect the estimates beside the actual outputs. Join multiplicity depends on each key’s frequency in both inputs.

Inspect 2 recorded results and plans

uniform

SELECT count(*) AS n FROM uniform WHERE key=0

Complete result:

[
  {
    "n": 100
  }
]

Native total_bytes_read: 0. Interpret this as the engine’s reported byte counter. An in-memory input still requires CPU and memory work.

PositionNative operatorEstimateOutput rowsOwn rows scannedNative details
0.0UNGROUPED_AGGREGATENot reported10
{
  "Aggregates": "count_star()"
}
0.0.0SEQ_SCAN 10310010,000
{
  "Table": "uniform",
  "Type": "Sequential Scan",
  "Projections": "",
  "Filters": "key=0",
  "Estimated Cardinality": "103"
}

Download uniform native profile · Complete result SHA-256: 4b5975cfbafa512614c1808d5376af0a8fe15937048ab837d73b3f324686a9a4

skew

SELECT count(*) AS n FROM skew WHERE key=0

Complete result:

[
  {
    "n": 9505
  }
]

Native total_bytes_read: 0. Interpret this as the engine’s reported byte counter. An in-memory input still requires CPU and memory work.

PositionNative operatorEstimateOutput rowsOwn rows scannedNative details
0.0UNGROUPED_AGGREGATENot reported10
{
  "Aggregates": "count_star()"
}
0.0.0SEQ_SCAN 20009,50510,000
{
  "Table": "skew",
  "Type": "Sequential Scan",
  "Projections": "",
  "Filters": "key=0",
  "Estimated Cardinality": "2000"
}

Download skew native profile · Complete result SHA-256: 02516a999d7a18c180c3364777160454a90ee5a2364b289b801aada66b4bc305

Refresh an estimate after changing the distribution.

Start with the uniform table and ANALYZE it. Then UPDATE key=id, changing 100 distinct keys into 10,000. Compare the updated table before and after another ANALYZE.

The answer changes from 100 matching rows to one after the update. Before refreshing, the scan estimate remains 103; afterward it is 2. The refreshed estimate is closer and still not exact. The pinned engine’s estimates let you compare its before-and-after view of the same data.

Inspect 3 recorded results and plans

stats-before

Before the query:

ANALYZE uniform
SELECT count(*) AS n, sum(id)::BIGINT AS total FROM uniform WHERE key=43

Complete result:

[
  {
    "n": 100,
    "total": 499300
  }
]

Native total_bytes_read: 0. Interpret this as the engine’s reported byte counter. An in-memory input still requires CPU and memory work.

PositionNative operatorEstimateOutput rowsOwn rows scannedNative details
0.0PROJECTION110
{
  "Projections": [
    "n",
    "total"
  ],
  "Estimated Cardinality": "1"
}
0.0.0UNGROUPED_AGGREGATENot reported10
{
  "Aggregates": [
    "count_star()",
    "sum_no_overflow(#0)"
  ]
}
0.0.0.0PROJECTION1031000
{
  "Projections": "id",
  "Estimated Cardinality": "103"
}
0.0.0.0.0SEQ_SCAN 10310010,000
{
  "Table": "uniform",
  "Type": "Sequential Scan",
  "Projections": "id",
  "Filters": "key=43",
  "Estimated Cardinality": "103"
}

Download stats-before native profile · Complete result SHA-256: 8281a8301710ac3dbc8929c73d2c93a6736a8738351ad19585129345993fc2b1

stats-stale

Before the query:

ANALYZE uniform;
UPDATE uniform SET key=id
SELECT count(*) AS n, sum(id)::BIGINT AS total FROM uniform WHERE key=43

Complete result:

[
  {
    "n": 1,
    "total": 43
  }
]

Native total_bytes_read: 0. Interpret this as the engine’s reported byte counter. An in-memory input still requires CPU and memory work.

PositionNative operatorEstimateOutput rowsOwn rows scannedNative details
0.0PROJECTION110
{
  "Projections": [
    "n",
    "total"
  ],
  "Estimated Cardinality": "1"
}
0.0.0UNGROUPED_AGGREGATENot reported10
{
  "Aggregates": [
    "count_star()",
    "sum_no_overflow(#0)"
  ]
}
0.0.0.0PROJECTION10310
{
  "Projections": "id",
  "Estimated Cardinality": "103"
}
0.0.0.0.0SEQ_SCAN 103110,000
{
  "Table": "uniform",
  "Type": "Sequential Scan",
  "Projections": "id",
  "Filters": "key=43",
  "Estimated Cardinality": "103"
}

Download stats-stale native profile · Complete result SHA-256: 7a9914d46ab8dc57945c34b4a5f0352f8fe423f06c201d81114a96d942dea2ce

stats-refreshed

Before the query:

ANALYZE uniform;
UPDATE uniform SET key=id;
ANALYZE uniform
SELECT count(*) AS n, sum(id)::BIGINT AS total FROM uniform WHERE key=43

Complete result:

[
  {
    "n": 1,
    "total": 43
  }
]

Native total_bytes_read: 0. Interpret this as the engine’s reported byte counter. An in-memory input still requires CPU and memory work.

PositionNative operatorEstimateOutput rowsOwn rows scannedNative details
0.0PROJECTION110
{
  "Projections": [
    "n",
    "total"
  ],
  "Estimated Cardinality": "1"
}
0.0.0UNGROUPED_AGGREGATENot reported10
{
  "Aggregates": [
    "count_star()",
    "sum_no_overflow(#0)"
  ]
}
0.0.0.0PROJECTION210
{
  "Projections": "id",
  "Estimated Cardinality": "2"
}
0.0.0.0.0SEQ_SCAN 2110,000
{
  "Table": "uniform",
  "Type": "Sequential Scan",
  "Projections": "id",
  "Filters": "key=43",
  "Estimated Cardinality": "2"
}

Download stats-refreshed native profile · Complete result SHA-256: 7a9914d46ab8dc57945c34b4a5f0352f8fe423f06c201d81114a96d942dea2ce

The same answer can pass through different intermediate sizes.

Join 100,000 fact rows to a repeated-key table and a selective table. Run the same SQL with normal optimization, then disable join_order and build_side_probe_side to preserve its written join sequence.

Both return n=1,000 and total=45,004,500. The earlier join produces 100 rows in the automatic plan and 1,000 in the written sequence. Dynamic filtering reduces the fact scan in both runs before the join materializes its result.

Inspect 2 recorded results and plans

join-auto

SELECT count(*) AS n, sum(f.id)::BIGINT AS total FROM fact f JOIN wide w USING(b) JOIN selective s USING(a) WHERE s.keep=1

Complete result:

[
  {
    "n": 1000,
    "total": 45004500
  }
]

Native total_bytes_read: 0. Interpret this as the engine’s reported byte counter. An in-memory input still requires CPU and memory work.

PositionNative operatorEstimateOutput rowsOwn rows scannedNative details
0.0PROJECTION110
{
  "Projections": [
    "n",
    "total"
  ],
  "Estimated Cardinality": "1"
}
0.0.0UNGROUPED_AGGREGATENot reported10
{
  "Aggregates": [
    "count_star()",
    "sum(#0)"
  ]
}
0.0.0.0PROJECTION4957791,0000
{
  "Projections": "id",
  "Estimated Cardinality": "495779"
}
0.0.0.0.0HASH_JOIN4957791,0000
{
  "Join Type": "INNER",
  "Conditions": "b = b",
  "Estimated Cardinality": "495779"
}
0.0.0.0.0.0HASH_JOIN490821000
{
  "Join Type": "INNER",
  "Conditions": "a = a",
  "Estimated Cardinality": "49082"
}
0.0.0.0.0.0.0SEQ_SCAN 100000100100,000
{
  "Table": "fact",
  "Type": "Sequential Scan",
  "Projections": [
    "b",
    "a",
    "id"
  ],
  "Estimated Cardinality": "100000"
}
0.0.0.0.0.0.1SEQ_SCAN 50001010,000
{
  "Table": "selective",
  "Type": "Sequential Scan",
  "Projections": "a",
  "Filters": "keep=1",
  "Estimated Cardinality": "5000"
}
0.0.0.0.0.1SEQ_SCAN 10001,0001,000
{
  "Table": "wide",
  "Type": "Sequential Scan",
  "Projections": "b",
  "Estimated Cardinality": "1000"
}

Download join-auto native profile · Complete result SHA-256: 4afb141d95c2b722ac1d8e0f5713d88011c909e93fc71de13eb64115e4b18ea2

join-written

Before the query:

SET disabled_optimizers='join_order,build_side_probe_side'
SELECT count(*) AS n, sum(f.id)::BIGINT AS total FROM fact f JOIN wide w USING(b) JOIN selective s USING(a) WHERE s.keep=1

Complete result:

[
  {
    "n": 1000,
    "total": 45004500
  }
]

Native total_bytes_read: 0. Interpret this as the engine’s reported byte counter. An in-memory input still requires CPU and memory work.

PositionNative operatorEstimateOutput rowsOwn rows scannedNative details
0.0PROJECTION110
{
  "Projections": [
    "n",
    "total"
  ],
  "Estimated Cardinality": "1"
}
0.0.0UNGROUPED_AGGREGATENot reported10
{
  "Aggregates": [
    "count_star()",
    "sum(#0)"
  ]
}
0.0.0.0PROJECTION01,0000
{
  "Projections": "id",
  "Estimated Cardinality": "0"
}
0.0.0.0.0HASH_JOIN01,0000
{
  "Join Type": "INNER",
  "Conditions": "a = a",
  "Estimated Cardinality": "0"
}
0.0.0.0.0.0HASH_JOIN1000001,0000
{
  "Join Type": "INNER",
  "Conditions": "b = b",
  "Estimated Cardinality": "100000"
}
0.0.0.0.0.0.0SEQ_SCAN 100000100100,000
{
  "Table": "fact",
  "Type": "Sequential Scan",
  "Projections": [
    "b",
    "a",
    "id"
  ],
  "Estimated Cardinality": "100000"
}
0.0.0.0.0.0.1SEQ_SCAN 10001,0001,000
{
  "Table": "wide",
  "Type": "Sequential Scan",
  "Projections": "b",
  "Estimated Cardinality": "1000"
}
0.0.0.0.0.1SEQ_SCAN 100001010,000
{
  "Table": "selective",
  "Type": "Sequential Scan",
  "Projections": "a",
  "Filters": "keep=1",
  "Estimated Cardinality": "10000"
}

Download join-written native profile · Complete result SHA-256: 4afb141d95c2b722ac1d8e0f5713d88011c909e93fc71de13eb64115e4b18ea2

Reproduce these observations

Captured 2026-09-12T22:57:39.637430+00:00 on Darwin / arm64. Each query starts a fresh connection with one thread. Only the Parquet comparisons have file-level byte counters. No timing rankings or portable speed thresholds are asserted.

Complete capture and hashes · Pinned recipe · Parquet with statistics · Parquet without statistics

Run uv run reproduce.py --output new-capture to regenerate the inputs and produce fresh native profiles. Run with --verify --output existing-capture to compare exact results, native estimates and cardinalities, preserved hashes and the stated pairwise differences. Timings and exact byte counters may vary; the recorded originals remain unchanged.

Only the temporary workspace path is replaced. Native timings and counters remain in each profile. Input/result digests cover canonical int64 Arrow IPC streams. Byte counters describe this engine execution, not physical disk traffic.

{
  "correlated": {
    "sql": "SELECT i%100 AS a, i%100 AS b FROM range(10000) t(i)",
    "rows": 10000,
    "sha256": "175ef45bfb2e7afd8cd5c5e8e5376c357333695868959e1f86b9543047862ced"
  },
  "independent": {
    "sql": "SELECT i%100 AS a, (i//100)%100 AS b FROM range(10000) t(i)",
    "rows": 10000,
    "sha256": "85879aadd1d33a53c8a0fe8a97d9e38e6a26f42b705c5b4f4e2883882b350ed0"
  },
  "uniform": {
    "sql": "SELECT i AS id, i%100 AS key FROM range(10000) t(i)",
    "rows": 10000,
    "sha256": "3229e23f047058d6416162c000bf5b413ee47d517b707050b0a5a01d7ce6789c"
  },
  "skew": {
    "sql": "SELECT i AS id, CASE WHEN i<9500 THEN 0 ELSE i%100 END AS key FROM range(10000) t(i)",
    "rows": 10000,
    "sha256": "92379ba72e19f93babcc05e7d569b3faccfbfd3a22be96ab7bc3317787254309"
  },
  "fact": {
    "sql": "SELECT i AS id, i%10000 AS a, i%100 AS b FROM range(100000) t(i)",
    "rows": 100000,
    "sha256": "e33c66be2414d6a8e11536242776ddd8ae6925448db66c2b8ff160547c630518"
  },
  "wide": {
    "sql": "SELECT i%100 AS b FROM range(1000) t(i)",
    "rows": 1000,
    "sha256": "704ba785faba6a4d9bb4ce04d55c0c84c8fa802ddc88b65df469cf671d53c2af"
  },
  "selective": {
    "sql": "SELECT i AS a, CASE WHEN i<10 THEN 1 ELSE 0 END AS keep FROM range(10000) t(i)",
    "rows": 10000,
    "sha256": "142063796db3493278342f91c9367ae9795a4c64e97a0b784a3e510eb86ec782"
  }
}

Join-order controls · ANALYZE · Parquet filter pushdown. The profiles here record DuckDB 1.4.4; current documentation can describe newer releases.

The answer becomes columns again

One ordered position connects three output columns.

The merge emits IDs 14–18. At each output position, the ID, description, and temperature must refer to the same joined row. Follow the selected observation across this final alignment.

Emitted row identity∅ No rowsTemperature buffer∅ No rowsDescription column∅ No rows
Only rows emitted by the merge have reached this live output. Lines follow the same identity between columns.

0 of five ordered rows have reached the output. The native complete result below remains available as the reference answer.

observation_id

slot 014

slot 115

slot 216

slot 317

slot 418

description

slot 0Southern site

slot 1Northern site

slot 2Southern site

slot 3Northern site

slot 4Southern site

temperature_c

slot 018

slot 119

slot 220

slot 321

slot 422

ID 14 occupies output slot 0. Its source position was 13; filtering changed positions, not the ID value.

observation_id · int64

0e 00 00 00 00 00 00 00

Value: 14 · byte offset 0

temperature_c · double

00 00 00 00 00 00 32 40

Value: 18 · byte offset 0

These are actual Arrow value buffers collected from native DataFusion and combined into contiguous columns. IDs use signed Int64, temperatures use Float64, and both occupy eight little-endian bytes per value.

Descriptions appear as decoded strings; their variable-width layout is explained in the Arrow guide. All five result rows are valid; the Arrow guide shows what changes when a slot is null.

Download the verified result buffers →

Follow validity into the buffers.

The Arrow guide follows null and empty values through their original buffers. The native result above connects that layout to a query engine’s output.

Optimization moves work toward its input.

For the warm-observation query, inspect the optimized DataFusion scan: the timestamp column is absent and the temperature predicate is present. Its physical source carries a pruning predicate, while a FilterExec still evaluates rows. These are distinct responsibilities: metadata can rule out a file or row group, then actual values decide which rows survive.

The join includes a Utf8-to-Utf8View cast because the lookup and Parquet scan expose different Arrow string representations. It preserves the same station values. The physical plan was captured before execution, so dynamic-filter contents describe planning time. Execution can populate them later.

Reproduce, then inspect.

Download normalized plans and exact results · Download unmodified plan captures. Recorded at 2026-09-10T19:19:04.604814+00:00. The weather file SHA-256 is 12e54f3643325c44da99b5508fc79bc83e92ee2a0da78ddf603d6d090064a4b5.

Save reproduce.py, plans.json and weather.parquet together, then run uv run reproduce.py. It executes both pinned engines and checks their results.

Read the DataFusion EXPLAIN documentation, Arrow columnar format and DuckDB walkthrough.

Your turn.

Run one of these questions with DuckDB, then reproduce the paired native plans. Find an operator that differs and trace the values it handles.