Short answer

In PostGIS, geometry and geography are two different spatial column types with two different ideas of what the Earth is. geometry treats every coordinate pair as a point on a flat plane and measures with ordinary Euclidean math, so its answers are in whatever units the column's CRS uses (degrees, meters, feet). geography treats coordinates as positions on the WGS84 spheroid and always measures in meters using geodesic (great-circle) formulas. Pick geometry with a projected CRS when your work fits inside one region and you want speed and the full function library; pick geography when data is global lon/lat and you need correct distances without choosing a projection.

How each type stores and measures

A geometry column is fundamentally agnostic about the planet. PostGIS stores the coordinates and an SRID tag, but ST_Distance, ST_Area, ST_Buffer, and the rest run Cartesian arithmetic on those raw numbers. If the column is geometry(Point, 4326), the "distance" between Paris and Berlin is the straight-line distance in degrees of lon/lat, which is geometrically meaningless as a ground distance. If the column is geometry(Point, 32633) (UTM zone 33N, meters), the same call returns a sensible distance in meters, accurate to a fraction of a percent inside that zone.

A geography column is built for the curved Earth. It assumes WGS84 (EPSG:4326) by default and computes distances along the spheroid. ST_Distance(a::geography, b::geography) between Paris and Berlin returns roughly 877,000 meters, the great-circle distance, regardless of how far apart they are or which UTM zone they fall in. That correctness is the whole point of the type.

The trade-off is direct: geodesic math is more expensive than planar math, and PostGIS implements far fewer functions for geography than for geometry. Operations like ST_Buffer, ST_Intersection, overlay, and many constructive functions are either unsupported on geography or quietly delegate to a planar approximation. So geography buys correctness over long distances at the cost of speed and breadth of operations.

Choosing between them

The decision usually comes down to extent and the operations you need.

Use geometry in a projected CRS when:

  • Your data lives inside one country, state, or UTM zone. A single UTM zone (6 degrees of longitude wide) keeps distortion under about 0.1% near the central meridian, which is fine for most engineering and geological work.
  • You need spatial overlays, buffers, clipping, snapping, or topology functions.
  • Query throughput matters; planar predicates and GiST indexes on projected geometry are fast.

Use geography when:

  • Data is genuinely global or continental and forcing it into one projection would distort large parts of it.
  • You only need distance, length, area, ST_DWithin, and containment, not the full constructive toolkit.
  • Your features cross UTM zone boundaries or the antimeridian, where a single planar projection breaks down.

A practical hybrid many teams use: store the canonical column as geometry(Point, 4326), build a functional GiST index using a cast to geography, and let the planner use it for proximity queries:

CREATE INDEX idx_sites_geog
  ON sites USING gist ((geom::geography));

That gives you a 4326 source of truth plus fast geodesic ST_DWithin lookups without duplicating data.

Worked example: a 25 km proximity query

Suppose you have a sites table with geom geometry(Point, 4326) and you want every site within 25 km of a borehole at lon 2.3522, lat 48.8566.

The wrong way, on raw geometry:

-- WRONG: 25000 is interpreted as 25000 DEGREES
SELECT id FROM sites
WHERE ST_DWithin(geom, ST_SetSRID(ST_MakePoint(2.3522, 48.8566), 4326), 25000);

This returns every row, because 25,000 degrees is the whole planet many times over. Casting to geography fixes the units:

-- CORRECT: 25000 meters, geodesic
SELECT id FROM sites
WHERE ST_DWithin(
        geom::geography,
        ST_SetSRID(ST_MakePoint(2.3522, 48.8566), 4326)::geography,
        25000);

Or, if all your data is regional, transform once into UTM 31N (EPSG:32631) and stay in planar meters:

SELECT id FROM sites
WHERE ST_DWithin(
        ST_Transform(geom, 32631),
        ST_Transform(ST_SetSRID(ST_MakePoint(2.3522, 48.8566), 4326), 32631),
        25000);

Both return meters-correct results. The geography version is simpler to reason about; the projected version is faster if you pre-transform and index the projected column.

ST_SetSRID is not ST_Transform

This is the single most damaging confusion in PostGIS, and it produces silently wrong output because nothing errors.

ST_SetSRID(geom, 4326) changes only the SRID integer stamped on the geometry. The coordinates are untouched. It is a labeling operation: "I am telling you these numbers are already in 4326." Use it only when the metadata is missing or wrong but the coordinates are genuinely in that CRS (for example, loading a raw ST_MakePoint that has SRID 0).

ST_Transform(geom, 32631) reprojects: it reads the source SRID, runs the coordinate operation through PROJ, and returns new coordinates in the target CRS. Use it whenever you actually need to move data between coordinate systems.

The classic failure: data arrives in UTM meters but its SRID is unset, and someone "fixes" it with ST_SetSRID(geom, 4326). The numbers are still hundreds of thousands of meters, now mislabeled as degrees of longitude, and every feature plots in the Gulf of Guinea or off-planet. The correct repair is ST_SetSRID(geom, <true_source_srid>) first (to label it honestly), then ST_Transform if you need a different CRS.

Indexing and performance differences

The two types index differently, and this often decides the choice for high-volume tables.

Both geometry and geography use GiST (or SP-GiST) spatial indexes built on bounding boxes, created the same way:

CREATE INDEX idx_parcels_geom ON parcels USING gist (geom);

For geometry, the bounding box is a simple 2D planar rectangle, and index lookups are fast and well understood. For geography, PostGIS computes bounding boxes on the sphere, which handles the poles and the antimeridian correctly but is more expensive to evaluate. In practice, planar geometry queries in a projected CRS are typically the fastest option, which is why analysts processing millions of features inside one region usually transform to a projected geometry column and stay there.

A second performance lever is the use_spheroid flag on geography functions. ST_Distance(a, b) on geography defaults to the (slower, more accurate) spheroidal calculation; passing false switches to a spherical approximation that is faster and usually within ~0.3% of the spheroidal answer, which is fine for many proximity tasks:

SELECT ST_Distance(a::geography, b::geography, false) AS dist_m;

For the common pattern of "is anything within X meters," prefer ST_DWithin over computing all distances and filtering, because ST_DWithin is index-assisted and short-circuits, whereas ST_Distance in a WHERE clause forces a full scan.

Common pitfalls and why they happen

  • Tiny decimal distances. ST_Distance on 4326 geometry returns degrees because the math is planar in the column's units. The cause is treating angular coordinates as if they were a metric plane. Cast to geography or transform to a projected CRS.
  • ST_Area on lon/lat geometry is nonsense. Area of "square degrees" has no physical meaning and a degree of longitude shrinks toward the poles. Use ST_Area(geom::geography) for square meters, or transform to an equal-area projection.
  • Mixing SRIDs in one function call. PostGIS raises Operation on mixed SRID geometries for many functions, but spatial predicates can also just return unexpected results. Enforce one SRID per column with a typmod like geometry(Point, 4326).
  • Assuming geography supports everything. Calling an unsupported constructive function on geography either errors or falls back to a planar shortcut. Check the PostGIS reference for geography support before relying on it.
  • Antimeridian and pole crossings on projected geometry. A single UTM zone cannot represent features that wrap around the 180th meridian; geography handles these natively.

QA and validation

Before trusting a spatial result, run a few cheap checks:

  • Confirm the column's declared SRID matches the actual coordinate ranges. Lon/lat values fall in roughly [-180, 180] and [-90, 90]; UTM eastings are around 100,000 to 900,000. SELECT DISTINCT ST_SRID(geom) FROM sites; plus an extent check with ST_Extent(geom) catches most mislabeling.
  • Validate geometries with ST_IsValid(geom) and locate problems with ST_IsValidReason; repair with ST_MakeValid.
  • Spot-check one or two known distances against an independent measurement before accepting a batch result.
  • For area, compare a geography cast result against a transform into a local equal-area CRS; large disagreement signals a CRS problem upstream.

Bathyl perspective

We default to a geometry(<type>, 4326) canonical column with a geography functional index, then transform into a regional projected CRS for analysis that needs buffers, overlays, or maximum speed. The deciding question on every table is not "which type is better" but "what is the largest extent any query will span, and which functions must run on it." Answer that first, and the geometry-vs-geography choice usually makes itself.

Related reading

Sources