Skip to main content

Command Palette

Search for a command to run...

PostGIS Geometry vs Geography: When to Use Each Type

A practical guide for PostgreSQL and TimescaleDB

Published
•4 min read•View as Markdown
PostGIS Geometry vs Geography: When to Use Each Type

PostGIS Geometry vs Geography: A Practical Decision Guide

Every PostGIS schema starts with a choice: geometry or geography. Make the wrong call and your spatial queries return silently incorrect results -- distances in degrees instead of meters, search radii that are 100x larger than intended, and proximity features that appear to work but serve wrong data to every user.

I have seen this exact bug in production more times than I can count. The pattern is always the same: a team stores latitude/longitude in a geometry(Point, 4326) column, writes proximity queries using ST_DWithin, and ships a feature that returns "nearby" results from 111 km away instead of 1 km. No error. No warning. Just confidently wrong output.

Understanding the Difference

The distinction comes down to math.

geometry uses Cartesian (flat-plane) calculations. It treats coordinates as X/Y values on a flat surface. For projected coordinate systems like UTM or State Plane -- where the projection has already accounted for Earth's curvature -- this is correct. The units are whatever the projection defines, typically meters.

geography uses geodesic (spherical) calculations. It knows the Earth is round. Distances are computed along the surface of a spheroid. The unit is always meters, regardless of location.

The problem arises when you put latitude/longitude (SRID 4326) into a geometry column. SRID 4326 is not a projected system -- it's an angular coordinate system where units are degrees. PostGIS does exactly what you ask: it calculates distances in degrees. ST_Distance between New York and London returns approximately 49.7 -- that's 49.7 degrees, not 49.7 anything useful.

Auditing Your Schema

Run these queries to understand what your database currently has:

-- All geometry columns
SELECT f_table_name, f_geometry_column, type, srid
FROM geometry_columns
ORDER BY f_table_name;

-- All geography columns
SELECT f_table_name, f_geography_column, type, srid
FROM geography_columns
ORDER BY f_table_name;

-- The danger zone: lat/long in geometry columns
SELECT f_table_name, f_geometry_column, type, srid
FROM geometry_columns
WHERE srid = 4326
  AND type IN ('POINT', 'MULTIPOINT');

That third query shows you every table where point data is stored in degrees. If any of these tables power proximity searches, distance calculations, or geofencing -- the results are in degrees, not meters.

The Migration

The safest approach adds a geography column without removing the existing geometry column:

ALTER TABLE locations ADD COLUMN geog geography(Point, 4326);
UPDATE locations SET geog = geom::geography;
CREATE INDEX CONCURRENTLY idx_locations_geog ON locations USING gist (geog);

Then update queries to use the new column:

-- Find locations within 1 km (1000 meters)
SELECT *
FROM locations
WHERE ST_DWithin(geog, ST_MakePoint(-73.98, 40.75)::geography, 1000);

-- Get distance in meters
SELECT name,
       ST_Distance(geog, ST_MakePoint(-73.98, 40.75)::geography) AS distance_meters
FROM locations
ORDER BY distance_meters
LIMIT 10;

The 1000 in ST_DWithin is now meters. Not degrees. Not an approximation that varies with latitude. Meters.

Keep the old geometry column until all consuming applications have migrated. Once nothing reads from it, drop it in a subsequent migration.

Function Availability: The Real Trade-off

Geography handles the common spatial operations: ST_Distance, ST_DWithin, ST_Area, ST_Length, ST_Perimeter, ST_Covers, ST_Intersects. These cover most web application use cases -- nearby search, distance calculation, containment checks.

But geography does NOT support the full PostGIS function library. Functions like ST_Buffer, ST_Union, ST_Intersection, ST_Difference, and ST_Simplify require geometry. If your application needs these, you have two options:

  1. Use geometry with a projected SRID. If your data is regional, pick the appropriate UTM zone or national grid. All functions work, and units are in meters because the projection handles the conversion.

  2. Use geography for storage, cast to geometry for processing. Store as geography, cast to a local projected CRS when you need advanced functions, then cast back. This adds complexity but keeps your storage correct.

-- Cast geography to a local projected CRS for buffer operation
SELECT ST_Buffer(
    ST_Transform(geog::geometry, 32618),  -- UTM zone 18N (New York area)
    500  -- 500 meters
) AS buffer_zone
FROM locations;

The Decision Framework

Three questions determine the right type:

Is your data global with lat/long coordinates? Use geography. Distances are in meters automatically. No projection selection needed. This is the correct default for most web applications -- store locators, delivery radius, ride-sharing, anything with latitude/longitude.

Is your data regional with a known projected CRS? Use geometry with that CRS. You get every PostGIS function and the units are already in meters (or whatever the CRS defines). Common examples: UTM zones for most of the world, State Plane for US states, British National Grid for the UK.

Do you need advanced geometry functions on global lat/long data? Start with geography for storage and index-backed proximity queries. Cast to a local projection when you need ST_Buffer, ST_Union, or similar. Accept the added complexity as the cost of correctness.

The worst choice is the one that happens by default: lat/long in a geometry column because that's what the first Stack Overflow answer used. Make the decision explicitly at schema design time and document it in the migration. The next developer should not have to guess what units ST_DWithin(col, point, 1000) operates in.


Originally published at mydba.dev/blog/postgis-geometry-vs-geography