← Back to Bounding Box & Spatial Index Queries
This page covers the single highest-value rewrite in a spatial API: turning a distance comparison that reads the whole table into an index-assisted predicate that reads a few hundred rows.
Context & When to Use
WHERE ST_Distance(geom::geography, $point) < 5000 looks like a filter and behaves like a table scan. PostgreSQL has no way to use a spatial index on it: ST_Distance is an ordinary function, so the planner must call it for every row before it can evaluate the comparison. On a two million row table that is two million geodesic distance computations to return perhaps forty rows.
ST_DWithin(geom::geography, $point, 5000) expresses the same intent in a form the planner understands. It is rewritten internally into an index-supported overlap test against an expanded bounding box, which the GiST index serves, followed by an exact distance recheck on the small candidate set. Same answer, three orders of magnitude less work.
The rewrite is mechanical and safe, and it belongs anywhere a radius appears: proximity search, geofence membership, “alert me when a vehicle comes within 200 m”. The one prerequisite people miss is the index — a geography predicate needs an index on the geography expression, and the existing geometry index will not serve it, as covered in Measuring Distance and Area in Metres with Geography.
Runnable Implementation
-- The index the predicate needs. Without it the rewrite happens and
-- still ends in a sequential scan, which is the confusing failure.
CREATE INDEX IF NOT EXISTS features_geog_gix
ON features USING GIST ((geom::geography));
-- ✕ Reads every row: ST_Distance is opaque to the planner
SELECT id, name
FROM features
WHERE ST_Distance(geom::geography,
ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography) < $3;
-- ✓ Index-assisted: expanded-box overlap, then an exact recheck
SELECT id, name,
ROUND(ST_Distance(geom::geography, p.pt)::numeric, 1) AS distance_m
FROM features f,
LATERAL (SELECT ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography AS pt) p
WHERE ST_DWithin(f.geom::geography, p.pt, $3)
ORDER BY f.geom::geography <-> p.pt
LIMIT $4;Note that ST_Distance still appears — in the SELECT list, where it runs once per returned row and costs nothing. The rule is the same one that governs transform placement: expensive functions belong on the output side, index-friendly predicates on the filter side.
Key Parameters & Options
| Construct | Index-assisted | Unit | Notes |
|---|---|---|---|
ST_DWithin(geog, geog, m) | yes | metres | The default choice for proximity |
ST_DWithin(geom, geom, deg) | yes | degrees | Fast and answers the wrong question |
ST_Distance(...) < r | no | — | Always a scan; rewrite it |
ST_Distance in SELECT | n/a | metres | Correct place for it |
<-> ordering | yes, with GiST | metres on geography | Pair with LIMIT for nearest-N |
| Radius from a column | no | — | Pre-compute a buffered column instead |
The per-feature radius case deserves a note, because it looks harmless and is not. ST_DWithin(a.geom, b.geom, a.catchment_m) cannot be index-assisted, since the expansion differs per row. The fix is to materialise ST_Buffer(geom::geography, catchment_m)::geometry into its own indexed column and test overlap against that.
Reading the plan
The rewrite either happened or it did not, and the plan says which in one line.
Where the rewrite stops helping
Index assistance is not unconditional. It works by narrowing the candidate set, so it delivers less and less as the radius grows relative to the data’s extent. Past a certain point the expanded box covers most of the table, every row becomes a candidate, and the plan reverts to a scan with an index lookup bolted on the front.
That crossover is worth knowing, because the fix is different on each side of it. Below it, the answer is always “make sure the index exists and the predicate is index-friendly”. Above it, no index will help and the answer is a coarser data structure: a pre-aggregated summary table, a materialized view, or simply refusing the request with a documented maximum radius.
Gotchas & Failure Modes
- The geography index missing.
ST_DWithinon geography with only a geometry index still scans. The predicate is correct and the plan is not; the index on(geom::geography)is the fix. - Casting only one operand. Mixing a geometry and a geography argument either raises
function st_dwithin(geometry, geography, numeric) does not existor silently resolves to the geometry overload with a degree radius. Cast both. - A radius that is actually a column. Index assistance requires the radius to be constant for the scan. Materialise a buffered geometry if each feature has its own catchment.
ST_DWithinwith a huge radius. At continental scale the expanded box covers most of the table and the index stops helping. Above roughly a tenth of the data’s extent, a different access path — or a coarser pre-aggregated table — is needed.- Sorting by
ST_Distanceafter filtering withST_DWithin. Correct, but it re-computes distance for every candidate. Use the<->operator, which the same index serves for ordering. ANALYZEnever run after a bulk load. Without statistics the planner may estimate the candidate set as the whole table and choose a scan anyway. RunANALYZEafter any large import, as in Managing Async Transactions for Bulk Geometry Writes.
One last practical note: the rewrite is worth applying even where the current table is small enough that nobody notices. A distance filter that scans two hundred thousand rows in forty milliseconds looks perfectly healthy in review, and it becomes a two-second query the quarter the table reaches four million. Because the rewrite costs nothing and reads no worse, there is no reason to defer it until the metric turns red.
One last practical note: the rewrite is worth applying even where the current table is small enough that nobody notices. A distance filter that scans two hundred thousand rows in forty milliseconds looks perfectly healthy in review, and it becomes a two-second query the quarter the table reaches four million. Because the rewrite costs nothing and reads no worse, there is no reason to defer it until the metric turns red.
Verification Snippet
-- Prove the rewrite happened
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM features
WHERE ST_DWithin(geom::geography,
ST_SetSRID(ST_MakePoint(-0.1276, 51.5072), 4326)::geography, 5000);
-- Index Scan using features_geog_gix on features
-- Index Cond: ((geom)::geography && _st_expand(...))
-- Rows Removed by Recheck: 302
-- And that both forms agree on the answer
SELECT count(*) FROM features
WHERE ST_DWithin(geom::geography, $1::geography, 5000);
SELECT count(*) FROM features
WHERE ST_Distance(geom::geography, $1::geography) < 5000;
-- identical counts, wildly different timingscurl -s "localhost:8000/v1/nearby?lon=-0.1276&lat=51.5072&radius_m=5000" \
| jq '.results | length, (.[0].distance_m)'
# 38
# 184.6Related
- Bounding Box & Spatial Index Queries — the index behaviour this relies on
- Measuring Distance and Area in Metres with Geography — why the cast is there at all
- Reading EXPLAIN ANALYZE for Spatial Query Optimization — the plan vocabulary in full
← Back to Bounding Box & Spatial Index Queries