Short answer
A spatial index is a tree of feature bounding boxes that lets a GIS engine answer "what is near here?" without reading every feature. Without one, a query like "give me the parcels intersecting this map view" forces a sequential scan: the engine tests the geometry of all 2 million parcels even if 50 are relevant. With an R-tree or GiST index, it first narrows to the few hundred whose bounding boxes overlap the view, then runs the expensive exact-geometry test only on those. On large layers this is the difference between milliseconds and minutes.
Different containers implement this differently — a sidecar .qix for shapefiles, an internal R-tree for GeoPackage, a GiST index in PostGIS — but the principle is identical and the failure mode is identical: a missing or stale index makes spatial queries crawl.
How a spatial index actually works
Geometries are arbitrarily complex — a coastline polygon can have 100,000 vertices. Testing whether two such polygons intersect is computationally expensive. A spatial index sidesteps most of that work with a two-phase strategy:
- Filter (the index). Every feature is reduced to its minimum bounding rectangle (MBR) — four numbers. These rectangles are organised into a hierarchical tree (an R-tree: rectangles grouped into parent rectangles, recursively). To find features near a query window, the engine walks the tree, pruning whole branches whose parent rectangle cannot overlap the window. This returns a small candidate set.
- Refine (the geometry). Only for candidates does the engine run the exact predicate (
ST_Intersects,ST_Contains, etc.) on full geometry.
The index never gives the final answer by itself — it gives a cheap shortlist. That is why "the index is just bounding boxes" is not a weakness; it is the whole point.
PostGIS: GiST indexes
PostGIS builds spatial indexes using PostgreSQL's GiST (Generalized Search Tree) framework, which implements an R-tree over geometry bounding boxes.
CREATE INDEX idx_parcels_geom ON parcels USING GIST (geom);
ANALYZE parcels;
The ANALYZE step is not optional: the query planner needs up-to-date statistics to choose the index over a sequential scan. After a large bulk load, always analyse.
To confirm the index is used, wrap the query in EXPLAIN ANALYZE:
EXPLAIN ANALYZE
SELECT id FROM parcels
WHERE ST_Intersects(geom, ST_MakeEnvelope(500000, 4600000, 501000, 4601000, 32630));
Look for Index Scan using idx_parcels_geom or Bitmap Index Scan. If you see Seq Scan, the index is missing, stale, or the predicate is not index-able (a common cause: wrapping geom in a function like ST_Buffer(geom, ...) on the left side of the comparison, which defeats the index). For very large tables also consider BRIN indexes when data is spatially sorted, and remember GiST indexes use the bounding box, so the SRID must match between the column and the query envelope.
GeoPackage: internal R-tree
GeoPackage is an OGC standard built on a SQLite container. Its spatial index is an R-tree implemented as SQLite virtual tables (the rtree module), stored inside the .gpkg file alongside triggers that keep the index in sync as features change. This is a real advantage over shapefiles: the index travels with the data in a single file.
GDAL creates the R-tree by default when you write a GeoPackage. You can add one explicitly with the GeoPackage extension function gpkgAddSpatialIndex, and you can verify it exists by querying the metadata:
SELECT * FROM gpkg_extensions WHERE extension_name = 'gpkg_rtree_index';
When you convert to GeoPackage with ogr2ogr, the spatial index is built automatically; to be explicit:
ogr2ogr -f GPKG out.gpkg in.shp -lco SPATIAL_INDEX=YES
Shapefiles: sidecar index files
A shapefile is a set of sibling files (.shp geometry, .shx shape index, .dbf attributes, .prj CRS). The .shx is not a spatial index — it is a record-offset index for fast seeking by record number. The actual spatial index is a separate sidecar:
.qix— the open quadtree index used by GDAL/OGR, QGIS, and MapServer..sbn/.sbx— Esri's proprietary spatial index.
Without a .qix (or .sbn), every spatial query against the shapefile scans the whole .shp. Build a .qix with GDAL:
ogrinfo -sql "CREATE SPATIAL INDEX ON parcels" parcels.shp
In QGIS this is Layer Properties → Source → Create Spatial Index. Because the index is a separate file, it is easy to lose: copy only .shp/.shx/.dbf/.prj to a new folder and the index is gone, and an edited shapefile can leave a stale .qix that no longer matches the geometry. This fragility is one of the strongest practical arguments for GeoPackage over shapefile.
GeoJSON and other text formats
GeoJSON (RFC 7946, WGS84 longitude-latitude) has no built-in spatial index. It is a plain text stream, so any query loads and parses the whole file. That is fine for a few thousand features on a web map but a poor choice for analytical or large datasets — those belong in GeoPackage, PostGIS, or a tiled format (vector tiles, FlatGeobuf, which carries an optional packed R-tree). If you must query large GeoJSON repeatedly, load it into PostGIS or convert to GeoPackage first.
Worked example: why it matters
A 1.8-million-feature parcel layer, querying the parcels in one city block:
- GeoJSON, no index: parse all 1.8 M features, ~tens of seconds, high memory.
- Shapefile, no
.qix: sequential scan of the.shp, seconds. - Shapefile with
.qix: quadtree narrows to a few hundred candidates, sub-second. - GeoPackage with R-tree: R-tree filter inside SQLite, sub-second, single portable file.
- PostGIS with GiST + ANALYZE: Bitmap Index Scan, milliseconds, and scales to concurrent users.
The geometry never changed. Only the access path did.
QA and validation before handoff
- PostGIS: confirm
CREATE INDEX ... USING GISTexists (\d tablein psql), that you ranANALYZE, and thatEXPLAIN ANALYZEshows an index scan on real queries. - GeoPackage: confirm the
rtree_*virtual tables exist andgpkg_extensionslistsgpkg_rtree_index. - Shapefile: confirm a
.qixsits next to the.shp, and that it was rebuilt after the last edit (a stale index returns wrong candidates silently). - Match SRIDs: an index over EPSG:32630 geometry queried with a 4326 envelope returns nothing useful — confirm the query CRS matches the column.
Common pitfalls and why they happen
- Stale or missing
.qix. Shapefiles were copied without the sidecar, or edited without rebuilding. The query "works" but is slow, or returns stale candidates. Rebuild it. - Forgetting
ANALYZEin PostGIS. The index exists but the planner, lacking statistics, still chooses a sequential scan. RunANALYZE. - Functions on the indexed column.
WHERE ST_Buffer(geom, 10) && ...cannot use the GiST index; buffer the query geometry instead, keepinggeombare on the left. - Indexing GeoJSON. It cannot be indexed in place; migrate to an indexed container.
- Assuming
.shxis the spatial index. It is not. You still need.qix.
Bathyl perspective
We regard the spatial index as part of the deliverable, not an internal detail, because an unindexed layer that opens fine on the desk grinds to a halt the moment it backs a web map or a multi-user database. Choosing GeoPackage or PostGIS over loose shapefiles is largely a decision to keep the index with the data so it cannot be lost in transit.
Related Bathyl reading
- KML vs GeoJSON for Spatial Data Sharing
- Vector Tiles for Web Maps
- Shapefile vs GeoPackage vs GeoJSON
- Spatial data products