DuckDB vs PostGIS Spatial Join Benchmarks

Benchmarking a point-in-polygon spatial join between DuckDB and PostGIS fairly means putting DuckDB’s R-tree plus && two-stage filter against PostGIS’s GiST index scan under identical inputs — and this walkthrough, part of the performance crossover benchmarks guide, gives a reproducible harness, the metrics that matter, representative numbers, and the plan comparison that explains them.

Root-Cause Analysis of Misleading Join Benchmarks

A spatial-join benchmark that compares the two engines honestly is surprisingly hard to construct, and almost every “engine X is N× faster” claim about spatial joins dies once you inspect how it was measured. The failure modes are specific to this operation:

  • Asymmetric indexing. DuckDB does not auto-index geometry and PostGIS does not either; one side ends up with a bounding-box index and the other with a full scan, and the ratio measures the missing index rather than the engine. Both a DuckDB R-tree and a PostGIS GiST index must exist and be used before a comparison means anything. Porting the index correctly is the subject of migrating GiST indexes to R-tree.
  • Predicate placed where the index cannot serve it. In DuckDB the && overlap must sit in the ON/WHERE against the bare indexed column; wrap it in a function and the R-tree is bypassed. In PostGIS the same discipline applies to && and ST_Intersects against a GiST-indexed column. A defeated index turns either engine into an O(N×M)O(N \times M) nested loop.
  • Cold cache on one side. The first execution reads from disk; the engine whose files were already cached wins for reasons that have nothing to do with join execution. Warm both engines before timing.
  • CRS or predicate drift. Different coordinate systems, or ST_Intersects on one side versus ST_DWithin with a tolerance on the other, make the two engines answer different questions at different costs.

Each of these produces a large, confident, wrong number, which is why plan validation — not the stopwatch — is the load-bearing step of the whole exercise.

There is also a subtler trap specific to the join case: the two engines can prune identically at the bounding-box stage yet disagree on the final match count if their exact predicates differ by a hair. A DuckDB ST_Intersects that treats a boundary-touching point as a match, versus a PostGIS run that filtered such points with a stricter ST_Contains, will produce different result sizes — and a benchmark comparing two different answers is timing two different queries. Always confirm the two engines return the same row count on the same inputs before you compare their speed; a matching count is the precondition that makes the timing ratio meaningful at all.

Deterministic Configuration

Fix the DuckDB session so the join is measured against a warm cache, an in-memory working set, and a real R-tree. Record these settings with the results.

INSTALL spatial;
LOAD spatial;

-- Match physical cores so the vectorized hash join actually parallelizes.
SET threads = 8;

-- Size above the join's working set so the measured run does not spill;
-- a spilling benchmark measures disk throughput, not the join.
SET memory_limit = '12GB';

-- Parallel R-tree build and scan; benchmark output order is irrelevant here.
SET preserve_insertion_order = false;

-- Remove progress-bar render cost from EXPLAIN ANALYZE timings.
SET enable_progress_bar = false;

Build the R-tree on the polygon layer before timing anything — the bounding-box index is what the two-stage filter routes through, and its internals are documented in R-tree spatial indexing internals.

-- The index the && stage will use. Without it, the join collapses to a full scan.
CREATE INDEX idx_zones_geom ON zones USING RTREE (geom);

-- Prove it registered before trusting a single timing.
SELECT index_name, table_name FROM duckdb_indexes() WHERE table_name = 'zones';

On the PostGIS side, the parity setup is CREATE INDEX idx_zones_geom ON zones USING GIST (geom); followed by ANALYZE zones; so the planner has fresh statistics, with work_mem set high enough that the join does not spill to pgsql_tmp.

Five conditions to equalise before comparing join timings Index parity, cache parity, result parity, thread parity and geometry parity — the five ways a spatial join benchmark is accidentally rigged. CONDITION WHY IT MATTERS IF IGNORED index parity both indexed, or neither worth 10–100× cache parity both warm, or both cold worth 5–20× result parity same rows out, checksummed two different queries thread parity comparable CPU budget a hardware comparison geometry parity same vertices, same precision vertex count dominates

Equalise all five and the remaining difference is the engines. Equalise four and it is whichever you missed.

Optimized Execution Pattern

The benchmark query assigns 5M points to 10K polygons. The naive form names the exact predicate once and lets each engine fall into a nested loop; the optimized form exposes the bounding-box stage so the index drives the scan.

-- Before: single exact predicate, no exposed bbox stage → nested loop, O(N × M).
SELECT p.id, z.zone_id
FROM points p
JOIN zones z ON ST_Intersects(z.geom, ST_Point(p.lon, p.lat));

-- After: bbox overlap in ON (index-servable) gates exact topology on survivors only.
SELECT p.id, z.zone_id
FROM points p
JOIN zones z
  ON z.geom && ST_Point(p.lon, p.lat)              -- stage 1: R-tree MBR overlap
WHERE ST_Intersects(z.geom, ST_Point(p.lon, p.lat)); -- stage 2: exact on the survivors

The only change is predicate placement, and it is the entire benchmark: && against the bare z.geom is index-servable, so DuckDB replaces the nested loop with an R-tree scan that hands a small candidate set to ST_Intersects. The identical rewrite applies to PostGIS. Both engines now run the same two-stage algorithm — the comparison is finally between their execution of that algorithm, not between an indexed plan and an unindexed one. The predicate-placement rules generalize in the spatial joins and proximity filters reference.

DuckDB R-tree two-stage plan versus PostGIS GiST index-scan plan for the same join Two vertical plan trees side by side, each read bottom to top. The left tree is DuckDB: a SEQ_SCAN of points at the bottom, an R-tree scan of zones using the b.geom bounding-box overlap in the middle, and a HASH_JOIN with an exact ST_Intersects recheck at the top. The right tree is PostGIS: a Seq Scan of points at the bottom, a GiST Index Scan of zones on the same bounding-box overlap in the middle, and a Nested Loop with an exact ST_Intersects recheck at the top. A caption notes that both prune with a bounding-box index and then recheck exact topology, but DuckDB runs a vectorized hash join while PostGIS runs a per-tuple nested loop. DuckDB · R-tree two-stage PostGIS · GiST index scan HASH_JOIN + ST_Intersects on survivors R-tree scan · zones z.geom && pt SEQ_SCAN · points Nested Loop + ST_Intersects recheck Index Scan · gist z.geom && pt Seq Scan · points Both prune with a bbox index then recheck exact topology; DuckDB joins vectorized, PostGIS joins per tuple.

Diagnostic Queries & Plan Validation

Capture the measured plan on both engines and confirm the intended access path before recording any timing. On DuckDB, EXPLAIN ANALYZE must show the R-tree-driven scan feeding a hash join, not a nested loop over the full polygon set.

EXPLAIN ANALYZE
SELECT p.id, z.zone_id
FROM points p
JOIN zones z
  ON z.geom && ST_Point(p.lon, p.lat)
WHERE ST_Intersects(z.geom, ST_Point(p.lon, p.lat));

Read three things: the join operator (HASH_JOIN healthy, NESTED_LOOP_JOIN a defeated index), the spill indicator (must be empty on the peak-throughput run), and actual-versus-estimated rows at the join (drift beyond ~30% means stale statistics or a non-selective bounding-box stage). On PostGIS, EXPLAIN (ANALYZE, BUFFERS) must show an Index Scan using idx_zones_geom rather than a Seq Scan, and Buffers: shared hit rather than read on a warm run.

Representative numbers for 5M points against 10K polygons on a single 8-core node with an SSD, both engines warm and indexed:

Metric DuckDB (R-tree + &&) PostGIS (GiST)
Warm median wall-clock ~0.6 s ~4.5 s
Peak resident memory ~1.8 GB ~0.5 GB (server buffers)
bbox stage pruning 85–99% of pairs 85–99% of pairs
Exact ST_Intersects checks survivors only survivors only
Join operator HASH_JOIN, vectorized Nested Loop over index matches
Scaling to 50M points ~5–7 s, in memory ~55–70 s, index-bound

The gap is not the index — both prune identically — it is what happens after pruning: DuckDB feeds the surviving candidates through a vectorized hash join and SIMD topology kernels over whole vectors, while PostGIS walks the survivors one tuple at a time through the nested loop. DuckDB’s higher resident memory is the flip side of holding the working set in columnar buffers; PostGIS’s lower figure reflects its server buffer pool and its per-tuple, low-footprint execution. Neither number is “better” in isolation — they describe different execution models, which is the whole point of the performance crossover benchmarks framing.

Which engine wins depends on the join shape, not the row count Selective probe joins favour PostGIS; wide joins across whole layers favour DuckDB; repeated joins over loaded data favour DuckDB once the load is amortised. selective — small probe, big layer a few hundred probes against an indexed layer PostGIS wins index descent per probe is what a server is built for wide — two large layers most rows on both sides participate in the join DuckDB wins parallel scan and hash build, no per-row descent repeated — same layers, many filters loaded once, joined often with varying predicates DuckDB wins scan cost amortises; no connection or planning tax The row count barely appears in any of these. What decides the winner is what fraction of the data the join touches, and how often.

Geometry Validation & Fallback Routing

Invalid polygons skew a join benchmark silently: a self-intersecting zone can make ST_Intersects throw on some engines, return corrupt matches on others, and inflate or deflate the survivor count so the two systems no longer agree on the answer. Validate the polygon layer once, before indexing, on both sides.

-- Quarantine invalid geometry before it poisons the benchmark and the index.
SELECT zone_id FROM zones WHERE NOT ST_IsValid(geom);

-- Repair deterministically, then rebuild the R-tree so MBRs match the fixed geometry.
UPDATE zones SET geom = ST_MakeValid(geom) WHERE NOT ST_IsValid(geom);
DROP INDEX IF EXISTS idx_zones_geom;
CREATE INDEX idx_zones_geom ON zones USING RTREE (geom);

Two fallbacks keep a large run comparable rather than crashing. When the point layer will not fit alongside the polygons and index within memory_limit, chunk the probe side into batches and union the counts, so DuckDB streams rather than spilling — a spilling run measures the disk, not the join. When edge noise matters and you want to benchmark a tolerance join instead of strict intersection, swap ST_Intersects for ST_DWithin with an identical distance on both engines so the comparison stays honest:

-- Tolerance variant: keep the && pre-filter so the R-tree still drives the scan.
SELECT p.id, z.zone_id
FROM points p
JOIN zones z
  ON z.geom && ST_Point(p.lon, p.lat)
WHERE ST_DWithin(z.geom, ST_Point(p.lon, p.lat), 0.0001);  -- same tolerance on PostGIS

Confirm both engines return the identical match count before trusting any timing ratio — a benchmark where the two systems disagree on the answer is measuring a bug, not performance.

Frequently Asked Questions

What is the most common way a join benchmark is accidentally rigged?

Index parity. PostGIS tables usually carry a GiST index that has been there for years; DuckDB tables freshly loaded from Parquet usually carry nothing. The resulting factor is real, but it measures index presence rather than engine capability. Index both sides or neither, and say which you did.

Should I benchmark warm or cold?

Both, reported separately, because they answer different questions. Cold tells you what a first query after a deploy will feel like; warm tells you what a repeated dashboard query costs. Reporting only the warm number and calling it “the” result is how benchmarks come to disagree with production.

Why does DuckDB lose on my selective join?

Because a selective join is the shape a server is built for. Fetching a few hundred rows through an index costs almost nothing in PostGIS and gains nothing from DuckDB’s parallelism — there is not enough work to spread. That is not a defect; it is the workload boundary, and it does not move with dataset size.

How many runs should I average?

Ten, reporting the median and the spread rather than the mean or the best. Spatial queries have long tails driven by which geometries happen to be compared, and a mean is easily dominated by one unlucky run. If the spread is wide, that is itself the finding — an engine with unpredictable latency is a different proposition from one that is merely slower.

See also

Up: DuckDB vs PostGIS vs GeoPandas Performance · Migrating to DuckDB Spatial