Problem Statement
ST_DWithin(geom, point, 500) needs the 500 to mean metres, and whether it does depends entirely on the column’s type and SRID. This page settles the choice behind every radius search: geography, projected geometry, or both — with the accuracy and CPU numbers that make the decision rather than the folklore.
Why the Naive Approach Fails
The default for imported data is geometry(Point, 4326), whose units are degrees. A radius expressed in metres is silently wrong, and the two common repairs are each wrong in their own way.
Converting metres to degrees by dividing by 111,320 works at the equator and degrades with latitude — the north-south direction stays right, the east-west direction stretches, and the “circle” becomes an ellipse that gets more eccentric the further from the equator you are.
Casting to geography inline gets the units right and disables the index:
-- correct units, no index: every row is measured
WHERE ST_DWithin(geom::geography, $1::geography, 500)Production-Ready Implementation
The right answer depends on extent, and there are only two cases worth implementing.
Data confined to one region — use a projected geometry. Pick the UTM zone or national grid that covers the extent, store the geometry in it, and index that column. Distances are planar, in metres, accurate to a few centimetres across the zone, and cheap to compute:
ALTER TABLE stops ADD COLUMN geom_utm geometry(Point, 32633); -- UTM 33N
UPDATE stops SET geom_utm = ST_Transform(geom, 32633);
CREATE INDEX CONCURRENTLY stops_geom_utm_idx ON stops USING gist (geom_utm);
-- metres, planar, index-driven
SELECT id FROM stops
WHERE ST_DWithin(geom_utm, ST_Transform($1, 32633), 500);Data spanning zones or the globe — use geography. Store geography(Point, 4326) and index it. Distances are spheroidal, correct everywhere, including across the antimeridian and near the poles:
ALTER TABLE ports ADD COLUMN geog geography(Point, 4326);
UPDATE ports SET geog = geom::geography;
CREATE INDEX CONCURRENTLY ports_geog_idx ON ports USING gist (geog);
SELECT id FROM ports
WHERE ST_DWithin(geog, $1::geography, 50000); -- 50 km, true metresThe Python side is identical in both cases; only the column and the parameter transform differ:
from sqlalchemy import select, func
def stops_near_projected(session, wkt_4326: str, metres: float):
"""Projected variant: transform the parameter, never the column."""
param = func.ST_Transform(func.ST_GeomFromText(wkt_4326, 4326), 32633)
return session.scalars(
select(Stop).where(func.ST_DWithin(Stop.geom_utm, param, metres))
).all()
def ports_near_geography(session, wkt_4326: str, metres: float):
"""Geography variant: cast the parameter, never the column."""
param = func.ST_GeogFromText(wkt_4326)
return session.scalars(
select(Port).where(func.ST_DWithin(Port.geog, param, metres))
).all()Both functions share the rule that matters: the transformation or cast is applied to the parameter, which is a constant the planner evaluates once, and never to the column, which would disable the index.
Configuration and Tuning Knobs
use_spheroid is ST_DWithin’s optional third argument on geography. Passing false uses a sphere rather than the WGS-84 spheroid, which is roughly twice as fast and introduces up to 0.3% error — about three metres in a kilometre. For “shops near me” that is invisible; for anything surveyed it is not.
Which column to index follows which column the query filters. Storing both a projected geometry and a geography is legitimate — one for measurement, one for global correctness — but only index the one the hot query uses, because the second index costs writes and serves nothing.
Expression indexes are the middle path when adding a column is impractical:
CREATE INDEX CONCURRENTLY stops_geog_expr_idx
ON stops USING gist ((geom::geography));The query must then use exactly geom::geography for the planner to match the expression — a small syntactic contract that is easy to break in an ORM.
Verification Steps
-- do the two approaches agree at your latitude?
WITH probe AS (SELECT ST_SetSRID(ST_MakePoint(10.75, 59.91), 4326) AS p) -- Oslo
SELECT
ST_Distance(ST_Transform(p, 32633),
ST_Transform(ST_Project(p::geography, 500, radians(90))::geometry,
32633)) AS projected_metres,
ST_Distance(p::geography,
ST_Project(p::geography, 500, radians(90))) AS geography_metres
FROM probe;
-- both should read ~500Then confirm the index is used, which is the failure that matters more than the arithmetic:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM stops WHERE ST_DWithin(geom_utm, ST_Transform($1, 32633), 500);
-- expect: Index Scan using stops_geom_utm_idxGotchas Checklist
geographyuses metres always;geometryuses whatever the SRID does. There is no configuration that changes this, and no shortcut that avoids knowing which column you have.- A cast on the column disables the index; a cast on the parameter does not. This single rule explains most “why is my radius query slow” reports.
- UTM zones are six degrees wide. Data that crosses a zone boundary is distorted at the far edge, which is exactly the case geography exists for.
ST_Distanceon geography returns metres; on geometry it returns SRID units. Mixing the two in one codebase produces numbers that look plausible and are not comparable.- Geography indexes are GiST too. Nothing about the index type changes; only the operator class and the distance semantics do.
Measuring the Error You Are Trading Away
The argument for a projected geometry column is speed; the argument against it is accuracy. Both are measurable, and the measurement takes about five minutes, so there is no reason to argue about it in the abstract.
Pick a hundred representative point pairs from the actual table, spread across the full extent of the data, and compare the two answers directly:
SELECT max(abs(
ST_Distance(a.geog, b.geog)
- ST_Distance(ST_Transform(a.geom, 3857), ST_Transform(b.geom, 3857))
)) AS worst_metres
FROM sample_pairs a JOIN sample_pairs b ON b.id = a.pair_id;Run it once with the projection you are considering and once with Web Mercator, and the numbers will settle the question. Web Mercator distorts distance by roughly 1 / cos(latitude): about two percent at 12 degrees, thirteen percent at 30 degrees, and more than forty percent at 55 degrees. For a city-scale dataset at high latitude, a five-kilometre radius search in EPSG:3857 can be off by two kilometres, which is not a rounding error — it is a different answer.
A local projection behaves far better. A UTM zone or a national grid holds distance error under a tenth of a percent across its intended extent, which for a radius search means a few metres in five kilometres. That is smaller than the positional error of consumer GPS, so it is genuinely negligible — right up until the data crosses the zone boundary, at which point the error grows without any warning in the query results.
The decision rule that follows is mechanical. If the data fits inside one local projection zone and stays there, project and take the speed. If it spans zones, crosses hemispheres, or grows unpredictably, use geography and pay for correctness. Documenting which case applies, in a comment on the column, saves the next engineer from re-deriving it.
Related Topics
- ST_DWithin Radius Searches — parent topic: the radius query end to end
- ST_DWithin vs ST_Distance for Proximity Filtering — the other half of getting a radius query right
- In-Place SRID Reprojection — adding the projected column safely
- KNN Nearest Neighbor Queries — when the question is “the closest few” rather than “within X”