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:

sql
-- correct units, no index: every row is measured
WHERE ST_DWithin(geom::geography, $1::geography, 500)
What a degree radius looks like on the ground Three panels at the equator, 40 degrees north and 60 degrees north. A fixed radius in degrees traces a circle at the equator, a moderately stretched ellipse at 40 degrees, and a strongly stretched one at 60 degrees, while the true metric circle stays circular in all three. A degree radius is a circle in exactly one place equator they coincide 40°N 30% too wide east-west 60°N twice as wide east-west Solid: the true 500 m circle. Dashed: what a fixed degree radius actually selects.

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:

sql
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:

sql
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 metres

The Python side is identical in both cases; only the column and the parameter transform differ:

python
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.

Accuracy and cost by approach and extent Three approaches compared. Degrees on geometry 4326 is fastest and wrong outside the equator. Projected geometry is fast and accurate within one zone. Geography is two to four times slower per evaluation and accurate everywhere. Pick by extent, not by preference geometry(Point, 4326) with a degree radius fastest · correct only near the equator · never the right answer for a product geometry(Point, 32633) — a projected column fast · centimetre-accurate within the zone · needs a second column geography(Point, 4326) 2–4× the CPU per exact test · correct at any extent · the safe global default Cost per exact distance evaluation Microseconds per exact distance test: planar geometry 0.4, geography on a sphere 0.9, and geography on the WGS-84 spheroid 1.6. Against an index-driven query evaluating two thousand candidates these are 0.8, 1.8 and 3.2 milliseconds. CPU per exact distance test geometry (planar) 0.4 µs · 0.8 ms over 2,000 candidates geography, sphere 0.9 µs · use_spheroid = false geography, spheroid 1.6 µs · the default, and the accurate one On an index-driven query the difference is milliseconds. On a full scan it is the whole query.

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:

sql
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

sql
-- 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 ~500

Then confirm the index is used, which is the failure that matters more than the arithmetic:

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM stops WHERE ST_DWithin(geom_utm, ST_Transform($1, 32633), 500);
-- expect: Index Scan using stops_geom_utm_idx

Gotchas Checklist

  • geography uses metres always; geometry uses 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_Distance on 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:

sql
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.