Short answer
A spatial index in PostGIS lets the query planner avoid testing every row's geometry against every other. The default is a GiST index on the geometry column, which stores each feature's bounding box in a balanced tree; spatial operators like && and the functions built on it (ST_DWithin, ST_Intersects) probe that tree to find candidate rows, then run the exact, expensive geometry test only on the small set that survives. Without it, a spatial join degrades to a nested loop of full geometry comparisons, and a query that should take milliseconds can take minutes.
How a GiST spatial index actually works
PostGIS spatial indexing rests on a two-phase model that is worth understanding because it explains almost every performance surprise.
Phase 1 — the index filter (cheap, approximate). A GiST (Generalized Search Tree) index does not store full geometries. It stores each geometry's minimum bounding rectangle (MBR) — four numbers — in a hierarchical, balanced tree. When you ask "which roads intersect this polygon?", the planner first uses the bounding-box overlap operator && to walk the tree and collect every row whose MBR overlaps the query box. This is fast and discards the vast majority of rows.
Phase 2 — the recheck (expensive, exact). Bounding-box overlap is necessary but not sufficient: two MBRs can overlap while the actual geometries do not (think of two diagonal river segments). So PostGIS runs the precise predicate (ST_Intersects, ST_Contains, the real distance) only on the candidates from phase 1. This recheck is where the CPU goes, which is why shrinking the candidate set is the whole game.
Crucially, the index-aware functions call && for you internally. ST_Intersects(a, b) is effectively a && b AND _ST_Intersects(a, b). That hidden && is what makes the GiST index usable. Functions that lack it cannot use the index at all.
Creating and maintaining the index
CREATE INDEX idx_parcels_geom ON parcels USING GIST (geom);
ANALYZE parcels;
The ANALYZE is not optional. PostGIS gathers spatial statistics (a histogram of geometry distribution) that the planner uses to estimate selectivity. After a bulk load or a large update, stale statistics routinely cause the planner to pick a sequential scan when an index scan would win — ANALYZE (or autovacuum catching up) fixes it. After heavy churn, REINDEX can also recover a bloated index.
For 3D data, add gist_geometry_ops_nd so the index covers Z (and M):
CREATE INDEX idx_pts_geom_nd ON points USING GIST (geom gist_geometry_ops_nd);
ST_DWithin vs ST_Distance: the canonical mistake
This is the most frequent reason a proximity query is slow despite an index.
-- SLOW: cannot use the spatial index
SELECT * FROM wells
WHERE ST_Distance(geom, ST_SetSRID(ST_MakePoint(2.35, 48.85), 4326)) < 1000;
-- FAST: index-aware
SELECT * FROM wells
WHERE ST_DWithin(geom, ST_SetSRID(ST_MakePoint(2.35, 48.85), 4326)::geography, 1000);
ST_Distance(...) < 1000 computes an exact distance for every single row, then filters — the index is irrelevant. ST_DWithin(a, b, d) expands the query geometry's bounding box by d and uses && against the index first, so only nearby candidates ever reach the distance computation. On a table of a few hundred thousand points the difference is routinely two to three orders of magnitude.
Note the ::geography cast above: with geography, ST_DWithin's distance is in metres on the spheroid, which is usually what you want for "within 1 km". With geometry in a projected CRS the distance is in that CRS's units (metres for UTM, degrees for EPSG:4326 — and degrees are not a sane distance).
Reading EXPLAIN ANALYZE
Always confirm what the planner did rather than assuming. Prefix the query with EXPLAIN (ANALYZE, BUFFERS):
Index Scan using idx_parcels_geomorBitmap Heap Scan+Bitmap Index Scan— good, the index is in play.Seq Scan on parcels— the index was not used. Reasons: a non-index-aware operator, a function wrapping the column (ST_Transform(geom, 4326) && boxdefeats an index ongeom; reproject the query box to the column's SRID instead), stale stats, or a table so small the planner correctly prefers a scan.- Compare
rowsestimated vs actual. A large gap means stale statistics — runANALYZE.
A worked tuning pass: a spatial join of 800k parcels against 50k flood polygons ran for minutes as a nested loop with sequential scans. Adding CREATE INDEX ... USING GIST (geom) on both tables, running ANALYZE, and rewriting ST_Distance(...) < 0 style predicates to ST_Intersects(...) turned it into a bitmap-index-driven join completing in seconds.
When GiST is not the right index
- BRIN (Block Range INdex): minuscule and cheap to maintain, it stores the bounding box of ranges of physical pages. It only helps when rows that are near each other geographically are also near each other on disk — for example a table bulk-loaded tile-by-tile, or points sorted on a geohash. On randomly ordered data BRIN gives almost no benefit. Use it for huge, append-only, spatially clustered tables where a GiST index would be expensive to build and store.
- SP-GiST: useful for certain point-only workloads; less common in practice.
- No index: for tiny lookup tables (a handful of admin boundaries), the planner is right to skip an index — the scan is faster than the tree probe.
To reinforce GiST's benefit on a frequently filtered region, CLUSTER parcels USING idx_parcels_geom physically reorders rows by index, improving cache locality (it is a one-time, blocking operation and not maintained automatically).
SRID metadata is not a transformation
A related trap that produces silently wrong index results: ST_SetSRID(geom, 4326) only labels the geometry; it does not move coordinates. If the stored numbers are actually Lambert-93 metres and you stamp them as SRID 4326, every spatial comparison and every ST_DWithin distance is meaningless, and the index dutifully returns wrong candidates fast. Use ST_Transform(geom, 4326) to actually convert. Mixing SRIDs in an operator also throws an error or, worse, compares incompatible coordinates — keep a single, declared SRID per column (enforced by a geometry(Point, 2154) typmod) so the index and the math agree.
QA and validation
Before trusting a spatially indexed query in production: confirm EXPLAIN ANALYZE shows an index or bitmap scan, not a seq scan; verify the geometry column has a single, correct SRID (SELECT DISTINCT ST_SRID(geom) FROM t); run ANALYZE after bulk loads; spot-check that ST_DWithin results match a manual ST_Distance on a sample; and confirm geometries are valid (ST_IsValid) since invalid geometries can make exact predicates error or behave unexpectedly.
Bathyl perspective
We design schemas so the index can do its job: one declared SRID per geometry column, GiST indexes created and ANALYZEd as part of the load pipeline, and proximity logic written with ST_DWithin from the start. The payoff is a database where the same spatial truth answers many queries quickly and predictably, instead of a desktop export that only one person knows how to reproduce.
Related reading
- PostGIS for Geological Map Databases
- PostGIS for Web Mapping Backends
- Shapefile vs GeoPackage vs GeoJSON
- Spatial data products