Tuning ST_DWithin Radius and Join Order
Two decisions dominate the cost of a proximity join and neither is visible in the SQL: how large the radius is relative to the local density, and which relation becomes the hash build side. This walkthrough, part of the spatial joins and proximity filters reference, covers deriving the radius from density rather than convention, measuring which side should build, and the plan signals that say the optimizer chose differently from you.
Root-Cause Analysis: why a proximity join costs what it does
- The radius sets the candidate count, quadratically. Doubling the radius roughly quadruples the area searched and therefore the candidate pairs. A radius chosen with a safety margin is not conservative; it is expensive by the square of the margin.
- Density varies across the layer. One radius that yields forty candidates downtown yields four in a suburb and none in the countryside, so a single global value is wrong nearly everywhere.
- The build side is chosen by rows, not vertices. The optimizer builds its hash table from the relation it believes is smaller, and for spatial data the useful measure of size is total vertex count. The two disagree routinely.
- The unit is whatever the coordinates are. A radius of 500 means 500 degrees on a geographic layer, which is most of the planet, and 500 metres on a projected one. The same query changes meaning entirely with the frame.
- The exact predicate runs on survivors, not on candidates.
ST_DWithincan answer from envelopes when they are further apart than the radius, so its cost is dominated by the pairs that survive the envelope test rather than by all pairs.
The distinguishing question is how many candidates the envelope stage produces per genuine match. That ratio is the tuning target, and both decisions on this page move it.
Nothing about the data changed across these four rows. Only the number in the predicate did.
Deterministic Configuration
INSTALL spatial; LOAD spatial;
SET memory_limit = '8GB';
SET threads = 8;
SET temp_directory = '/var/tmp/duckdb_join';
-- Build the index on the side that will be probed, after the data is loaded.
CREATE INDEX idx_shops_geom ON shops USING RTREE (geom);
Optimized Execution Pattern
The pattern is a two-stage predicate with a density-derived radius, and an explicit check on which side is heavier before assuming the optimizer chose well.
-- ANTI-PATTERN: a round-number radius applied globally, and no envelope stage
-- for the index to serve. Both problems compound.
SELECT i.incident_id, s.shop_id
FROM incidents i JOIN shops s
ON ST_DWithin(i.geom, s.geom, 500); -- 500 what? and no && to prune with
-- PATTERN: envelope stage first, then the distance guard, with the radius
-- taken per cell from a density table rather than fixed globally.
SELECT i.incident_id, s.shop_id, ST_Distance(i.geom, s.geom) AS metres
FROM incidents i
JOIN cell_radius r
ON r.cx = floor(ST_X(i.geom) / 1000)::INT
AND r.cy = floor(ST_Y(i.geom) / 1000)::INT
JOIN shops s
ON s.geom && ST_Expand(i.geom, r.r0) -- index-servable square window
WHERE ST_DWithin(i.geom, s.geom, r.r0); -- circular truth, on survivors
ST_Expand produces a square window that is a superset of the circle, which is exactly what makes it safe as a pre-filter: it lets through some corner pairs that the distance test then removes, and it never excludes a pair the distance test would have kept.
Two million points weigh 30 MB; fifty thousand boundaries weigh 1.1 GB.
Deriving the radius from density
A radius is only meaningful relative to how far apart the features are, and that varies by orders of magnitude within a single national layer. The derivation is straightforward: count features per grid cell once, invert to get a mean spacing, and scale it so the expected candidate count lands somewhere useful — a small multiple of however many neighbours the question wants.
Stored as a per-cell column, that estimate costs one join and removes the whole problem of a global value being wrong in most places. It also makes the query self-documenting: the radius is visibly a function of local density rather than a number somebody once chose.
-- One pass over the probed layer gives a per-cell radius that lands a
-- comparable candidate count everywhere, instead of forty downtown and none
-- in the countryside.
CREATE OR REPLACE TABLE cell_radius AS
SELECT floor(ST_X(geom) / 1000)::INT AS cx,
floor(ST_Y(geom) / 1000)::INT AS cy,
2.0 * sqrt(1000.0 * 1000.0 / greatest(count(*), 1)) AS r0
FROM shops
GROUP BY cx, cy;
Diagnostic Queries & Plan Validation
The plan says which side built and how many candidates survived, and both numbers are more useful than the runtime.
-- Look for the hash build side and the candidate count out of the join.
-- A build that spills before any output row appears means the heavy side
-- was chosen to build.
EXPLAIN ANALYZE
SELECT count(*)
FROM incidents i JOIN shops s ON s.geom && ST_Expand(i.geom, 250)
WHERE ST_DWithin(i.geom, s.geom, 250);
Compare candidates against matches. A ratio in the low tens is healthy for a proximity join; a ratio in the thousands means the radius is far larger than the density warrants, and shrinking it is worth more than any other change.
Only the first is a correctness bug. The other three are the same bug about size.
Reducing the heavy side before the join
When the vertex-heavy relation genuinely has to participate, the productive move is to make it lighter before the join rather than to fight the optimizer over which side builds. Two reductions apply. The first is an attribute filter: most proximity questions are asked about a subset — a category of shop, a period of incidents — and applying that before the join reduces both sides. The second is simplification: a boundary layer used only to answer “is this within 250 m” does not need centimetre vertices, and ST_SimplifyPreserveTopology at a tolerance well below the radius changes no answers while removing most of the weight.
Both are worth trying before reaching for a hint, partly because DuckDB offers no join hints and partly because they are usually larger wins than the join order would have been.
Geometry Validation & Fallback Routing
Where the join still will not fit, partition it on a key both sides share and run the pieces.
-- Partition-aligned proximity join. Correct whenever no genuine match can
-- cross a region boundary; where one can, widen the region rather than
-- distribute the join.
SELECT i.incident_id, s.shop_id
FROM incidents i
JOIN shops s
ON s.region = i.region -- restrict the pair space first
AND s.geom && ST_Expand(i.geom, 250)
WHERE ST_DWithin(i.geom, s.geom, 250);
Frequently Asked Questions
Why does my ST_DWithin return no rows?
Almost always a unit mismatch. On a geographic layer the radius is in degrees, so 500 means five hundred degrees and 0.0005 means roughly fifty metres. Check the coordinate range before adjusting the number: values inside plus or minus 180 mean the layer is still in degrees and the fix is a reprojection.
How should I choose the radius?
From local density rather than as a round number. Count features per cell once, invert to a mean spacing, and scale so the expected candidate count is a small multiple of the neighbours you want. A global radius is wrong nearly everywhere, and its cost grows with the square of how wrong it is.
Do I still need the bounding-box operator with ST_DWithin?
For the index to be used, yes. ST_DWithin can answer from envelopes internally, but the optimizer needs an index-eligible expression to route through the R-tree, and ST_Expand plus && is that expression. The distance test then removes the corner pairs the square window let through.
Which side should build the hash table?
The one with fewer total vertices, which is frequently not the one with fewer rows. Two million points weigh about thirty megabytes as a build side; fifty thousand municipal boundaries weigh over a gigabyte. Measure sum(ST_NPoints(geom)) before assuming the row counts explain anything.
Can I force the join order?
There are no join hints, so the productive route is to change what the optimizer sees: filter the heavy relation first, simplify its geometry to a tolerance well below the radius, or materialise a reduced version of it. Those are usually larger wins than the join order would have been anyway.
Is ST_Expand plus && the same as ST_DWithin?
No, and the difference is what makes the pair work. ST_Expand gives a square window; ST_DWithin describes a circle. The square is a superset, so it is safe as a pre-filter and unsafe as a replacement — used alone it returns corner features further away than the radius allows.
Related
- Spatial joins and proximity filters — the build-side question in full
- K-nearest-neighbour queries with ST_DWithin — the same radius decision, for a different question
- Spatial indexing internals — what the envelope stage is served by