Spatial Indexing in PostGIS with GiST
A spatial query over an unindexed PostGIS table scans every row; with a GiST index it jumps straight to candidates. This guide shows how to build, verify, and reason about GiST indexes so spatial predicates actually use them, driven from Python with SQLAlchemy 2.0. It is for anyone whose PostGIS queries are slower than expected. It sits under PostGIS Integration with Python in Spatial Analysis & Advanced Query Techniques.
Why This Approach / What Goes Wrong
PostGIS spatial predicates run in two phases. First a fast bounding-box filter — the && operator — compares the axis-aligned envelope of each geometry against the query envelope; this is the phase a GiST index accelerates. Only rows that survive the box test proceed to the second phase: the exact geometry computation (ST_Intersects, ST_Contains, ST_DWithin, and friends), which is far more expensive because it walks vertices rather than comparing four numbers.
A GiST (Generalized Search Tree) index stores those bounding boxes in a balanced tree, so the planner can descend to the handful of rows whose envelopes overlap the query instead of box-testing all of them. Without the index, the planner has no choice but a sequential scan: it reads every row and box-tests it inline. On a few thousand rows that is fine; on a few million it is the difference between milliseconds and minutes.
Three mistakes stop the index from doing its job:
- The index was never created.
to_postgisand rawCREATE TABLEdo not add a spatial index for you — you must create it explicitly. This is the same discipline that matters when you first load a GeoDataFrame into PostGIS. - The index exists but
ANALYZEnever ran. The planner is cost-based; without table statistics it cannot estimate how selective the predicate is and may fall back to a sequential scan even with a perfectly good index sitting there. - The predicate cannot use the index. Wrapping the indexed column in a function (
ST_Buffer(geom, ...)), comparing across different SRIDs, or using a non-index-aware operator all defeat it silently — no error, just a slow plan.
The goal is an EXPLAIN plan that shows an Index Scan or Bitmap Index Scan, not a Seq Scan.
Prerequisites
- A PostGIS table with a populated
geometrycolumn and a known SRID sqlalchemy>=2.0— the engine and Coretext()APIpsycopg>=3.1— the driver (SQLAlchemy addresses it aspostgresql+psycopg)geopandas>=0.14— to read query results back as aGeoDataFrame
conda install -c conda-forge "sqlalchemy=2.0.*" "psycopg=3.1.*" "geopandas=0.14.*"
Step-by-Step Implementation
1. Create the GiST index on the geometry column. Use IF NOT EXISTS so the step is idempotent, and give the index a predictable name so you can spot it in EXPLAIN output.
from sqlalchemy import create_engine, text
engine = create_engine("postgresql+psycopg://gis:gis@localhost:5432/gisdb")
with engine.begin() as conn: # begin() commits on exit
conn.execute(text(
"CREATE INDEX IF NOT EXISTS roads_gix ON roads USING GIST (geometry)"
))
For a table already carrying live traffic, build the index without taking a write lock:
# CREATE INDEX CONCURRENTLY cannot run inside a transaction block,
# so use an AUTOCOMMIT connection rather than engine.begin().
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
conn.execute(text(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS roads_gix "
"ON roads USING GIST (geometry)"
))
On a large table the build itself is worth tuning. GiST index creation is memory-bound, and the default maintenance_work_mem of 64 MB forces PostgreSQL to spill while assembling the tree. Raising it for the session routinely halves build time on a multi-million-row layer:
with engine.begin() as conn:
conn.execute(text("SET maintenance_work_mem = '1GB'")) # session-scoped, not global
conn.execute(text("CREATE INDEX IF NOT EXISTS roads_gix ON roads USING GIST (geometry)"))
There is also a version cliff here. PostGIS 3.2 on PostgreSQL 14 or newer builds GiST indexes on geometry by sorting along a space-filling curve instead of inserting rows one at a time, which is several times faster and produces a better-packed tree. If an index build on a big table is taking an hour on an older stack, the upgrade is a bigger win than any parameter.
2. Run ANALYZE so the planner gets row and selectivity statistics. PostGIS ships a custom estimator for spatial columns, but it only has data to work with after ANALYZE samples the table.
with engine.begin() as conn:
conn.execute(text("ANALYZE roads"))
The estimator builds a two-dimensional histogram of the column's extent and uses it to guess how many rows a given query window will return. That guess is what decides between an index scan and a sequential scan, so it degrades in two predictable situations: when the data is extremely clustered (a national table where 90% of rows sit in three cities) and when the extent has outliers (one stray geometry at 0°, 0° stretches the histogram over the whole planet and makes every real window look tiny). Deleting null-island rows before indexing is not tidiness — it measurably improves plan quality. For a heavily skewed column you can also raise the sample size with ALTER TABLE roads ALTER COLUMN geometry SET STATISTICS 1000; and re-run ANALYZE.
3. Write predicates the index can use — keep the indexed column bare and the SRIDs matched. The && operator and index-aware functions such as ST_Intersects and ST_DWithin recheck the exact geometry internally but let the planner reach for the GiST index for the box phase.
import geopandas as gpd
# ST_Intersects hits the GiST index for the bbox phase, then does the exact test.
# ST_MakeEnvelope's last argument is the SRID — it MUST match the column's SRID.
window_sql = """
SELECT r.road_id, r.geometry
FROM roads r
WHERE ST_Intersects(
r.geometry,
ST_MakeEnvelope(7.6, 45.0, 7.8, 45.1, 4326)
)
"""
window = gpd.read_postgis(window_sql, engine, geom_col="geometry")
4. Avoid index-defeating patterns. The single most common way to lose the index is to transform the indexed column. Buffer or transform the constant side instead, or switch to a function that is index-aware by construction.
# BAD: ST_Buffer wraps the indexed column, so the GiST index can't be used.
# WHERE ST_Intersects(ST_Buffer(r.geometry, 250), :depot)
#
# GOOD: ST_DWithin is index-aware — it uses the index and applies the
# distance internally, so nothing wraps r.geometry.
# WHERE ST_DWithin(r.geometry, :depot, 250)
Note the distance unit: ST_DWithin measures in the column's own units. On a projected column (metres) 250 means 250 m; on a geographic geometry column in EPSG:4326 it means 250 degrees, which is almost never what you want. For metric radius searches, either store the column in a projected CRS such as the appropriate UTM zone for your data or cast to geography (ST_DWithin(geometry::geography, :depot, 250)), which measures in metres on the ellipsoid and still uses a GiST index. This is exactly the pattern behind nearest-neighbour and radius search inside the database.
5. Reach for a variant index only when the plain GiST index is provably the bottleneck. PostGIS supports three operator-class families on geometry, and they are not interchangeable:
- GiST is the default and the right answer roughly always. It handles any geometry type, supports the
<->distance operator for KNN ordering, and copes with mixed sizes. - SP-GiST (space-partitioned GiST, available for geometry since PostGIS 2.5) uses a quadtree instead of an R-tree. It builds faster and can be smaller on uniformly distributed point data, but it has no advantage on polygons and does not support the same set of operators. Benchmark it on your own workload before adopting it; the usual outcome is a wash.
- BRIN stores one summary box per block range instead of one entry per row, so the index is tiny — kilobytes where GiST needs gigabytes. It only works when rows are physically stored in spatial order, which in practice means data loaded already sorted by geohash or by region and never updated. On unsorted data every block range covers the whole extent and the index prunes nothing.
Two index shapes matter more often than the operator class. A partial index skips rows you never query, which shrinks the tree and speeds the build:
with engine.begin() as conn:
conn.execute(text(
"CREATE INDEX IF NOT EXISTS roads_active_gix ON roads USING GIST (geometry) "
"WHERE status = 'active'"
))
The planner only uses that index for queries whose WHERE clause implies status = 'active', so the predicate has to appear in the query too. And when a spatial filter is nearly always paired with an attribute filter, a multi-column index over both columns lets one index scan do both jobs — but this needs the btree_gist extension, because a plain GiST index cannot hold a text or integer column:
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE INDEX roads_class_geom_gix ON roads USING GIST (road_class, geometry);
Order matters: put the equality-tested column first. Measure before and after, because two separate indexes combined by a bitmap AND are frequently just as fast and far more flexible.
Verification
Use EXPLAIN ANALYZE to confirm the planner actually chose an index scan. Reading the plan from Python lets you assert on it in a test.
from sqlalchemy import create_engine, text
engine = create_engine("postgresql+psycopg://gis:gis@localhost:5432/gisdb")
with engine.connect() as conn:
plan = conn.execute(text(
"EXPLAIN ANALYZE SELECT road_id FROM roads "
"WHERE geometry && ST_MakeEnvelope(7.6, 45.0, 7.8, 45.1, 4326)"
)).fetchall()
plan_text = "\n".join(row[0] for row in plan)
print(plan_text)
# Index Scan using roads_gix on roads (cost=0.28..8.30 rows=1 ...)
# Index Cond: (geometry && '0103...'::geometry)
# ...
assert "Index Scan" in plan_text or "Bitmap Index Scan" in plan_text
Both Index Scan and Bitmap Index Scan mean the index is working — Postgres picks the bitmap variant when it expects to return many rows. Seeing Seq Scan instead means the index is missing, ANALYZE was not run, or the predicate is not index-eligible.
You can also confirm the index physically exists and see how large it grew:
with engine.connect() as conn:
info = conn.execute(text("""
SELECT indexname, pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_indexes
JOIN pg_class ON pg_class.relname = indexname
JOIN pg_index ON pg_index.indexrelid = pg_class.oid
WHERE tablename = 'roads' AND indexdef ILIKE '%USING gist%'
""")).fetchall()
print(info) # [('roads_gix', '12 MB')]
A one-off EXPLAIN proves the index can be used; production tells you whether it is. pg_stat_user_indexes counts every scan since the last statistics reset, so an index with idx_scan = 0 after a week of traffic is dead weight — it slows every write and occupies disk for nothing:
with engine.connect() as conn:
usage = conn.execute(text("""
SELECT indexrelname, idx_scan, idx_tup_read
FROM pg_stat_user_indexes
WHERE relname = 'roads'
ORDER BY idx_scan DESC
""")).fetchall()
print(usage)
# [('roads_gix', 148213, 2914558), ('roads_active_gix', 0, 0)]
Zero scans on a spatial index almost always means the application is filtering in Python after fetching, not that the index is broken — pushing the predicate into SQL is the fix, as covered in connecting GeoPandas to PostGIS.
Edge Cases & Debugging
- Planner ignores the index on a small table. A sequential scan is genuinely cheaper for a few thousand rows — the planner is right. Test index behaviour with realistic data volumes, not a toy table.
Seq Scandespite an index. RunANALYZE roads; then check theWHEREclause is not wrapping the indexed column in a function. If both are fine, the predicate may simply match most of the table, in which case a scan is optimal.- Index not used after a bulk load. Statistics are stale after large inserts. Run
ANALYZE(orVACUUM ANALYZE) once the load finishes — see how a full-table replace also drops the index in connecting GeoPandas to PostGIS. - Cross-SRID predicate returns nothing or errors. The box test cannot compare geometries in different SRIDs. Match the SRID in
ST_MakeEnvelope/ST_SetSRID, orST_Transformboth sides to a common coordinate reference system first — reprojecting the indexed column, though, defeats the index, so transform your query geometry to the column's SRID instead. geographycolumn search is slow. GiST works ongeographytoo, but mixing casts (geometryin the column,geographyin the predicate) forces a per-row cast that skips the index. Index and query the same type consistently.- Index bloat after many updates. Heavy
UPDATE/DELETEchurn inflates a GiST index. Rebuild it withREINDEX INDEX CONCURRENTLY roads_gixto reclaim space without blocking writes. - The index is used but the query is still slow. The filter stage worked and the refine stage is the cost — typically one huge polygon whose bounding box covers everything. Split it with
ST_Subdivideas described in PostGIS Integration with Python. CREATE INDEX CONCURRENTLYleft an invalid index. A concurrent build that fails leaves a row inpg_indexwithindisvalid = falsethat the planner ignores but writes still maintain. Drop it explicitly and rebuild; it will not clean itself up.- The index disappeared after a reload.
to_postgis(if_exists="replace")drops and recreates the table, taking every index with it. Make index creation part of the load script, not a one-time setup step.
Frequently Asked Questions
Should I create the index before or after a bulk load? After, without exception. Loading into an indexed table maintains the tree on every insert, which can triple the load time and leaves a more fragmented index than a single build over the finished table. The one caveat is that the table is unindexed and therefore slow for readers during the load — if that matters, load into a staging table, index it there, and rename it into place.
Does an index on geometry also accelerate geography queries?
Only if the column and the predicate use the same type. A GiST index on a geometry column cannot serve ST_DWithin(geom::geography, ...), because the cast produces a value the stored envelopes do not describe — the plan quietly becomes a sequential scan. Either store the column as geography and index that, or add a functional index on the cast: CREATE INDEX ON sensors USING GIST ((geom::geography));.
How much disk does a GiST index cost?
Roughly 10–20% of the table's size for typical point and polygon layers, since each entry stores only a bounding box and a row pointer regardless of vertex count. That means the index over a table of 40 000-vertex coastlines is proportionally tiny, while an index over a table of points can approach the size of the data. Check the real number with pg_relation_size rather than estimating.
Why does ORDER BY geom <-> point LIMIT 5 beat ORDER BY ST_Distance(...) LIMIT 5?
The <-> operator is index-aware: GiST walks the tree in order of increasing distance and stops after five rows. ST_Distance is an ordinary function, so the planner has to compute it for every row and sort the lot. On a million-row table that is the difference between a millisecond and several seconds — the same distinction between a bounded and an unbounded search that drives the choices in Nearest Neighbor & KD-Tree Search.