PostGIS: The Definitive Spatial Layer for PostgreSQL
Radius search, geofencing, and map serving as indexed SQL. A production-focused guide to PostGIS: what it is, how to deploy it, and how to use it from SQLAlchemy
What PostGIS is
PostGIS is the open-source spatial extension for PostgreSQL. It has been in continuous development since 2001, implements the OGC Simple Features specification, and is the de facto standard spatial database in industry — it underpins OpenStreetMap infrastructure, national mapping agencies, and location platforms operating at global scale.
Enabled, it extends PostgreSQL with three capabilities:
- Spatial types.
geometryfor planar coordinates,geographyfor latitude/longitude on a spheroid, plus raster and topology types for specialized workloads. Points, lines, polygons, and their multi-part variants become first-class column types. - Spatial indexing. GiST, SP-GiST, and BRIN index methods make radius searches, containment tests, and nearest-neighbour queries index-driven at any scale.
- A complete spatial function library. Measurement, predicate testing, geometry processing, clustering, format conversion (WKT, GeoJSON, Mapbox Vector Tiles), and coordinate transformation across the full EPSG registry of roughly 9,000 spatial reference systems.
What it is for
PostGIS resolves an entire category of engineering problems at the database layer.
Proximity search. Retrieving every property within three kilometres of a reference point is one indexed query:
SELECT id, name
FROM properties
WHERE ST_DWithin(
location,
ST_SetSRID(ST_MakePoint(36.8172, -1.2864), 4326)::geography,
3000 -- meters
);
No application-side distance math. No sequential scans.
Nearest-neighbour ranking. The KNN operator <-> returns the closest rows using the spatial index directly:
SELECT id, name
FROM stations
ORDER BY location <-> ST_SetSRID(ST_MakePoint(36.82, -1.29), 4326)::geography
LIMIT 10;
Geofencing and zone assignment. ST_Contains and ST_Within answer point-in-polygon questions — which delivery zone, which county, which service area — as ordinary relational joins.
Territory analytics. Revenue aggregated by neighbourhood polygon, fleet coverage computed with ST_ConvexHull, and server-side pin clustering with ST_ClusterDBSCAN are all single SQL statements.
Map serving. ST_AsMVT produces Mapbox Vector Tiles directly from SQL. A tile endpoint becomes a query, not a separate service.
Route and track analysis. Linear referencing functions — ST_LineLocatePoint, ST_LineInterpolatePoint, ST_LineSubstring — snap GPS observations to routes and measure progress along them.
The principle throughout: spatial logic belongs beside the data, where it is indexed, transactional, and composable with the rest of the schema.
geometry vs geography: decide this first
The type decision precedes everything else, because changing it after data accumulates is a migration project.
geography |
geometry |
|
|---|---|---|
| Coordinate model | lat/lon on a spheroid | planar |
| Distance units | meters, always | units of the SRID (degrees for 4326) |
| Performance | slower per operation | faster |
| Function coverage | large subset | complete |
| Correct for | global and country-scale data | local data in a projected SRID |
The correct default is geography(Point, 4326). GPS coordinates are stored without transformation, every distance is returned in meters, and results are accurate everywhere on Earth. Reach for geometry in a projected reference system only when profiling identifies a hot path that justifies it — for Kenyan data, that projection is SRID 21037 (Arc 1960 / UTM zone 37S).
Production installation
PostGIS versions are bound to PostgreSQL versions. The non-negotiable rule: install from the PostgreSQL Global Development Group (PGDG) repositories. Distribution default packages lag by multiple releases and are unsuitable for production.
Ubuntu / Debian
# Add the PGDG repository
sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
# PostgreSQL 16 with PostGIS 3
sudo apt install -y postgresql-16 postgresql-16-postgis-3 \
postgresql-16-postgis-3-scripts
Extensions in PostgreSQL are database-scoped. Enable PostGIS in each application database:
CREATE EXTENSION postgis;
-- Enable only what the workload requires:
-- CREATE EXTENSION postgis_raster;
-- CREATE EXTENSION postgis_topology;
-- CREATE EXTENSION pgrouting;
Verify the installation and record the output in your runbook — it identifies the versions of PostGIS and its underlying libraries (GEOS, PROJ, GDAL), which is the first thing any debugging session requires:
SELECT postgis_full_version();
Docker
The postgis/postgis images are maintained by the PostGIS project and track PostgreSQL releases:
# docker-compose.yml
services:
db:
image: postgis/postgis:16-3.4
environment:
POSTGRES_DB: rentals
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
volumes:
- pgdata:/var/lib/postgresql/data
secrets:
- db_password
volumes:
pgdata:
secrets:
db_password:
file: ./secrets/db_password
The image enables PostGIS in the default database on first initialization; additional databases require the extension to be enabled explicitly, through versioned migrations. Pin the image to an exact major.minor tag. latest in production is an unplanned major upgrade waiting to happen.
Managed platforms
DigitalOcean Managed Databases, GCP Cloud SQL, AWS RDS and Aurora, and Azure Database all ship PostGIS pre-installed; enabling it is a single CREATE EXTENSION postgis;. Two facts govern managed deployments:
- Providers frequently trail the newest PostGIS release. Verify the shipped version before depending on recently added functions.
- Extension upgrades happen on the provider's schedule.
ALTER EXTENSION postgis UPDATE;runs when they make a version available, not before.
Hardening the deployment
Tune for spatial workloads. Spatial values are larger than typical row data and GiST index construction is memory-intensive. Beyond standard tuning, raise maintenance_work_mem (1–2 GB is a sound starting point) before building spatial indexes on large tables, and provision work_mem for sessions executing heavy spatial joins.
Build indexes without locking. On live tables:
CREATE INDEX CONCURRENTLY properties_location_idx
ON properties USING GIST (location);
Version the extension itself. CREATE EXTENSION IF NOT EXISTS postgis; belongs in the first migration of the project. Every environment — CI, staging, a new developer machine — must reach a working state from migrations alone.
Back up as PostgreSQL. PostGIS data is table data. pg_dump, WAL archiving, and pgBackRest operate unchanged. The one obligation: after a PostgreSQL major-version upgrade via pg_upgrade, run ALTER EXTENSION postgis UPDATE; and follow the PostGIS upgrade notes, because the extension consists of a SQL layer and a compiled library that must remain in agreement.
Install nothing speculatively. postgis_raster, postgis_topology, and the TIGER geocoder are separate extensions by design. Every enabled extension is an upgrade obligation.
SQLAlchemy integration
GeoAlchemy2 is the canonical bridge between SQLAlchemy and PostGIS. It supplies the column types and registers spatial functions with correct return-type handling.
pip install geoalchemy2 shapely
Shapely converts database geometries into Python objects and is effectively mandatory for any non-trivial use.
Models
from geoalchemy2 import Geography
from sqlalchemy import BigInteger, Text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class Property(Base):
__tablename__ = "properties"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
name: Mapped[str] = mapped_column(Text, nullable=False)
location: Mapped[str] = mapped_column(
Geography(geometry_type="POINT", srid=4326),
nullable=False,
)
GeoAlchemy2 creates the GiST spatial index automatically at table creation. Do not declare a second one.
Writes
EWKT is the most direct input format:
prop = Property(
name="Westlands Apartment",
location="SRID=4326;POINT(36.8095 -1.2673)", # lon lat — X then Y
)
session.add(prop)
session.commit()
Coordinate order is fixed by the standard: WKT is POINT(longitude latitude). Axis order is the single most common PostGIS integration error; points that land in the ocean east of Somalia instead of Nairobi have been swapped.
From Shapely objects:
from geoalchemy2.shape import from_shape
from shapely.geometry import Point
prop.location = from_shape(Point(36.8095, -1.2673), srid=4326)
Queries
Spatial functions are invoked through sqlalchemy.func:
from sqlalchemy import func, select
def properties_near(session, lng: float, lat: float, radius_m: int):
ref = func.ST_SetSRID(func.ST_MakePoint(lng, lat), 4326)
stmt = (
select(
Property,
func.ST_Distance(Property.location, ref).label("distance_m"),
)
.where(func.ST_DWithin(Property.location, ref, radius_m))
.order_by("distance_m")
)
return session.execute(stmt).all()
Two rules govern query performance:
- Filter with
ST_DWithin. It is index-aware.ST_Distance(...) < xin a WHERE clause is not, and forces a sequential scan. Identical semantics, categorically different execution plans. - On a
geographycolumn, distances are meters. No conversion layer is required or should be written.
Reading geometries back into Python:
from geoalchemy2.shape import to_shape
point = to_shape(prop.location)
point.x, point.y # 36.8095, -1.2673
Serializing directly to GeoJSON at the database:
stmt = select(func.ST_AsGeoJSON(Property.location)).where(Property.id == 42)
geojson_str = session.execute(stmt).scalar_one()
Async
GeoAlchemy2 compiles to SQL identically under every driver. Async sessions with asyncpg require no special treatment:
async def properties_near(session: AsyncSession, lng, lat, radius_m):
ref = func.ST_SetSRID(func.ST_MakePoint(lng, lat), 4326)
stmt = select(Property).where(func.ST_DWithin(Property.location, ref, radius_m))
result = await session.execute(stmt)
return result.scalars().all()
Alembic
Three requirements, each mandatory:
1. Import geoalchemy2 in migration files. Autogenerated migrations reference geoalchemy2.types.Geography; Alembic does not import it. Add the import to script.py.mako so every generated migration carries it:
import geoalchemy2
${imports if imports else ""}
2. Remove the duplicated spatial index. GeoAlchemy2 creates the GiST index inside create_table; Alembic autogenerate emits a second, redundant op.create_index(...) that fails at runtime. Delete the redundant create_index and matching drop_index from generated files, or adopt geoalchemy2.alembic_helpers, which handles this automatically in current releases.
3. Create the extension in the first migration.
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS postgis")
The migration role requires privileges for CREATE EXTENSION; on managed platforms the default administrative user has them.
Conclusion
PostGIS moves the hardest problems in location-aware systems — geodesic measurement, spatial indexing, coordinate reference systems — into a database layer with two decades of production hardening behind it. Deployment is a solved problem on every platform: PGDG packages on servers, the official image on Docker, one statement on managed databases. GeoAlchemy2 integrates the result into a SQLAlchemy codebase cleanly, subject to two invariants that separate correct implementations from broken ones: longitude precedes latitude, and radius filters use ST_DWithin.
For any system with a map in it, PostGIS is not one option among several. It is the standard, and the engineering question is only how early you adopt it.