Benchmarking Spatial Joins in DuckDB

An aggregate FILTER made this DuckDB spatial query 84× faster than a plain WHERE, with the same exact count and no index or precomputation.

Benchmarks ran on a 10-core, 32 GB MacBook Pro M2.

I found the difference while testing a local DuckDB replica of open geodata: 158 million building footprints in an 83 GiB file. I wanted to count buildings inside Columbus, Ohio. The exact answer is 319,513; the city boundary is an 18,239-vertex multipolygon. The slowdown came from how DuckDB planned the spatial predicate.

query time result
Full query with WHERE ST_Intersects(...) 35.4 s 319,513 buildings
Full query with aggregate FILTER 0.423 s 319,513 buildings
Bbox-prune stage alone (separate baseline) 0.065 s 553,278 candidates

EXPLAIN shows the difference. The WHERE form becomes a SPATIAL_JOIN and re-parses the polygon for each row (about 61 µs per call). Aggregate FILTER avoids that rewrite; the predicate uses GEOS's prepared-geometry path instead (about 0.7 µs per call).

WHERE  : UNGROUPED_AGGREGATE -> SPATIAL_JOIN(condition: st_intersects) -> SEQ_SCAN x2
FILTER : UNGROUPED_AGGREGATE(count_star() FILTER (WHERE ...))
                              -> PROJECTION -> CROSS_PRODUCT -> SEQ_SCAN x2

The pattern holds from a tiny village to a 9,238-vertex county; each pair returns the same count:

target vertices WHERE FILTER speedup
Columbus city, OH 18,239 35.4 s 0.423 s 84×
Coconino County, AZ 9,238 22.7 s 0.195 s 116×
San Francisco city, CA 873 1.586 s 0.145 s 11×
Ashtabula County, OH 378 0.247 s 0.025 s 10×
Yellow Springs, OH tiny 0.018 s 0.003 s 6×

Returning rows instead of a count

For endpoints that return rows rather than a count, aggregate FILTER is not a direct fit. These alternatives all return the same 319,513 rows:

form time
WHERE ST_Intersects(g, geom) 36.1 s
WHERE ST_Intersects(g, geom) IS TRUE 0.559 s
WHERE coalesce(ST_Intersects(g, geom), false) 0.656 s
array_agg(g) FILTER (WHERE ST_Intersects(g, geom)) + unnest 0.689 s

IS TRUE is the simplest drop-in. A ::BOOLEAN cast (37.4 s) and filtering a projected predicate in an outer query (33.3 s) remain slow.

The same pattern holds for point-in-polygon: count(*) FILTER (WHERE ST_Contains(poly, point)) took 0.033 s for 28,862 points, versus 4.09 s with WHERE — 124× slower.

The practical rule

IS TRUE changes the predicate shape the optimizer sees. Like MATERIALIZED, it is load-bearing: a future DuckDB version could optimize through it and bring back the slow plan. Check EXPLAIN after upgrades.

Keep the bbox prune to limit the exact test to candidates. An RTree index did not help: 35.1 s versus 33.7 s, and EXPLAIN showed no RTree scan.

Queries

These are the complete statements behind the two timings:

-- Full WHERE query: 35.4 s, result 319,513.
CREATE OR REPLACE TEMP TABLE where_result AS
WITH tgt AS (
  SELECT geometry AS geom, xmin, xmax, ymin, ymax, cx0, cx1, cy0, cy1
  FROM census_places WHERE geoid = '3918000'
), cand AS MATERIALIZED (
  SELECT b.geometry AS g FROM buildings b, tgt t
  WHERE b.cx BETWEEN t.cx0 AND t.cx1 AND b.cy BETWEEN t.cy0 AND t.cy1
    AND b.xmax >= t.xmin AND b.xmin <= t.xmax
    AND b.ymax >= t.ymin AND b.ymin <= t.ymax
)
SELECT count(*) AS buildings FROM cand c, tgt t
WHERE ST_Intersects(c.g, t.geom);

-- Full aggregate-FILTER query: 0.423 s, same result 319,513.
CREATE OR REPLACE TEMP TABLE filter_result AS
WITH tgt AS (
  SELECT geometry AS geom, xmin, xmax, ymin, ymax, cx0, cx1, cy0, cy1
  FROM census_places WHERE geoid = '3918000'
), cand AS MATERIALIZED (
  SELECT b.geometry AS g FROM buildings b, tgt t
  WHERE b.cx BETWEEN t.cx0 AND t.cx1 AND b.cy BETWEEN t.cy0 AND t.cy1
    AND b.xmax >= t.xmin AND b.xmin <= t.xmax
    AND b.ymax >= t.ymin AND b.ymin <= t.ymax
)
SELECT count(*) FILTER (WHERE ST_Intersects(c.g, t.geom)) AS buildings
FROM cand c, tgt t;

Notes

Each timed query is a single CREATE TEMP TABLE ... AS SELECT statement; I did not create a candidate table first and then query it. The bbox prune is a materialized stage within each query. The 35.4 s and 0.423 s figures are full query timings; 0.065 s is a separate bbox-only baseline. If the bbox prune is not materialized first, the planner can run the spatial test before applying the bbox filter, testing all 158 million rows. On Houston, that took 203 s instead of 1.3 s for the same 637,209 buildings.

I tested both DuckDB 1.5.6 and 2.0 alpha; neither fixed the slow WHERE plan. The 1.5.6 query still took ~21 s, while aggregate FILTER took ~0.24 s with the same result.


Co-Authored by DeepSeek Alpha 4.1 | Edited by GPT-6 Luna | Coded in Freebuff