How to Create a Spatial Index in SQLite with Python

From a sqlite3 connection with modspatialite loaded, call SELECT CreateSpatialIndex('table', 'geomcolumn') to build an R-tree index, then verify the…

From a sqlite3 connection with mod_spatialite loaded, call SELECT CreateSpatialIndex('table', 'geom_column') to build an R-tree index, then verify the planner uses it by running EXPLAIN QUERY PLAN on a bounding-box query and looking for a scan of the idx_<table>_<geom> virtual table.

This page sits under the Native sqlite3 & Spatial Extensions guide, which covers loading mod_spatialite and calling spatial SQL directly from the standard-library driver. Here the focus is narrow: building an R-tree spatial index from Python and proving that queries actually hit it.

Why This Matters

Without a spatial index, every bounding-box or nearest-feature query is a full table scan: SQLite computes the geometry predicate for all rows. On a 500,000-row parcel layer that turns a sub-second map redraw into a multi-second stall — unacceptable on field hardware. An R-tree index stores each geometry’s bounding box in a compact tree so the planner can discard almost every non-matching row before touching the geometry BLOB.

The saving comes from pruning, not from faster comparisons. An R-tree groups nearby features under a shared bounding rectangle, and groups those rectangles under larger ones, all the way to a single root. A bounding-box query descends the tree and discards any branch whose rectangle does not intersect the search window — and discarding a branch discards everything beneath it without ever reading a geometry.

How an R-tree prunes a bounding-box queryA three-level tree. The root node holds the bounding rectangle covering every feature. It has two branch nodes below it. Branch A overlaps the search window, so the query descends into its two leaf nodes and reads their entries. Branch B does not overlap the window, so it is pruned along with both of its leaves, and none of those geometries is ever read.root nodeMBR of the whole layerbranch Aoverlaps the windowbranch Bpruned — no overlapleaf 1entries readleaf 2entries readleaf 3never touchedleaf 4never touched
One rejected rectangle at branch level eliminates every geometry beneath it — which is why index depth matters far less than selectivity.

SpatiaLite and GeoPackage both build on SQLite’s R-tree module but wire it up differently. SpatiaLite exposes CreateSpatialIndex(), which creates an idx_<table>_<geom> virtual table and keeps it in sync with triggers. GeoPackage defines its own gpkg_rtree_index extension with tables named rtree_<table>_<geom>. Knowing which one your file uses — and confirming the planner picks it up — is the difference between an index that speeds queries and dead weight the optimizer ignores.

Prerequisites

  • Python 3.9+ with a sqlite3 build where enable_load_extension is available
  • mod_spatialite installed (apt install libsqlite3-mod-spatialite, brew install libspatialite, or the conda-forge librspatialite package)
  • A table with a registered geometry column (SpatiaLite metadata initialised, or a GeoPackage feature table)
  • Familiarity with loading mod_spatialite from sqlite3
  • GDAL/OGR 3.4+ only if you plan to cross-check with ogrinfo

Primary Method

python
# sqlite3 + mod_spatialite — build and verify an R-tree spatial index
import sqlite3


def create_spatial_index(db_path: str, table: str, geom_col: str = "geom") -> None:
    conn = sqlite3.connect(db_path)
    try:
        conn.enable_load_extension(True)
        conn.load_extension("mod_spatialite")

        # Ensure the spatial metadata exists (safe no-op if already initialised).
        # WGS84_ONLY keeps the srs_ref table small; use 'NONE' then insert your own.
        already = conn.execute(
            "SELECT count(*) FROM sqlite_master WHERE name='spatial_ref_sys'"
        ).fetchone()[0]
        if not already:
            conn.execute("SELECT InitSpatialMetaData(1)")

        # Build the R-tree. Returns 1 on success, 0 if it already exists.
        ok = conn.execute(
            "SELECT CreateSpatialIndex(?, ?)", (table, geom_col)
        ).fetchone()[0]
        conn.commit()

        if ok != 1:
            # 0 usually means the index already exists — recover it to be sure.
            conn.execute("SELECT RecoverSpatialIndex(?, ?)", (table, geom_col))
            conn.commit()
    finally:
        conn.close()

CreateSpatialIndex builds the idx_<table>_<geom> R-tree virtual table and installs triggers that keep it current as rows are inserted, updated, or deleted. It returns 1 on success and 0 when the index already exists or the geometry column is not registered.

Step-by-Step Walkthrough

1. Load the extension

The standard-library driver ships extension loading disabled. Enable it, then load mod_spatialite by its bare name so the OS resolver finds the shared object:

python
import sqlite3

conn = sqlite3.connect("parcels.gpkg")
conn.enable_load_extension(True)
conn.load_extension("mod_spatialite")
print(conn.execute("SELECT spatialite_version()").fetchone()[0])

2. Confirm the geometry column is registered

CreateSpatialIndex only works on a geometry column SpatiaLite knows about. Check the registry — if the row is missing, register it with RecoverGeometryColumn:

python
row = conn.execute(
    "SELECT srid, geometry_type FROM geometry_columns "
    "WHERE f_table_name = ? AND f_geometry_column = ?",
    ("parcels", "geom"),
).fetchone()
print(row)   # e.g. (4326, 3) -> POLYGON in EPSG:4326

3. Create the R-tree index

python
ok = conn.execute(
    "SELECT CreateSpatialIndex('parcels', 'geom')"
).fetchone()[0]
conn.commit()
print("created" if ok == 1 else "already existed / not registered")

4. Query through the index with a bounding-box predicate

An R-tree only helps when the query expresses a bounding-box filter. In SpatiaLite the idiom is a subquery against the idx_ virtual table:

sql
-- SpatiaLite: parcels intersecting a search window, using the R-tree
SELECT p.id, p.name
FROM parcels AS p
WHERE p.ROWID IN (
    SELECT ROWID FROM SpatialIndex
    WHERE f_table_name = 'parcels'
      AND search_frame = BuildMbr(-1.6, 50.9, -1.4, 51.1, 4326)
);

Note the shape of that query: it is two filters, not one. The R-tree subquery is cheap and approximate — it returns everything whose bounding box overlaps the window, including features whose actual geometry does not. The exact predicate then runs only on those survivors. Skipping the first filter means running the expensive test on every row; skipping the second returns false positives.

Rows surviving each stage of an indexed spatial queryA three-stage funnel over a parcels table of five hundred thousand rows. The R-tree search frame reduces that to eight hundred and twelve bounding-box candidates. The exact geometry predicate then reduces those to two hundred and fourteen true hits. Only the second stage decodes geometry, so the expensive test runs on 812 rows rather than 500,000.Two filters, cheap one first500,000 rows in parcelsno geometry decoded yet812 bounding-box candidatesR-tree search_frame — index only214 true hitsexact predicate on survivors
Bar widths are schematic; the counts are what a real 500k-row parcel layer produces for a small map window.

5. The GeoPackage variant

A GeoPackage feature table uses the OGC R-tree extension. Create it from Python against the rtree_<table>_<geom> naming:

sql
-- GeoPackage: create the OGC-defined R-tree on a feature table
SELECT gpkgAddSpatialIndex('parcels', 'geom');

If the gpkgAddSpatialIndex helper is unavailable in your mod_spatialite build, GDAL creates the same index when you write the layer with the SPATIAL_INDEX=YES layer creation option.

The two containers use the same underlying R-tree module, so the performance is identical — but almost every name around it differs, and code that hard-codes one set of names silently does nothing on the other format:

Spatial index wiring in SpatiaLite compared with GeoPackageTwo columns comparing four aspects. SpatiaLite creates an index with CreateSpatialIndex, names the virtual table idx followed by table and geometry column, keeps it current with SpatiaLite triggers, and is queried through the SpatialIndex virtual table with a search_frame. GeoPackage creates one with gpkgAddSpatialIndex, names it rtree followed by table and geometry column, keeps it current with the triggers the OGC extension defines, and is queried by joining the rtree table on its minx, maxx, miny and maxy columns.SpatiaLiteCreateSpatialIndex(t, g)idx_<table>_<geom>SpatiaLite triggers sync itquery via search_frameGeoPackagegpkgAddSpatialIndex(t, g)rtree_<table>_<geom>OGC extension triggersjoin on minx / maxx
Same R-tree module underneath; four different names on top. Detect the format before choosing the SQL.

Detecting which wiring a file uses is a single query against sqlite_master: a container with a gpkg_contents table is a GeoPackage and wants the rtree_ path, while one with geometry_columns and spatial_ref_sys in the SpatiaLite layout wants CreateSpatialIndex. Files that carry both — a GeoPackage that has had InitSpatialMetaData run against it — are legal but ambiguous, and the GeoPackage path should win, because that is what other readers will look for.

6. Populate or rebuild after a bulk load

The index triggers fire per-row, which slows large inserts. For a bulk load, it is faster to load first and build the index afterward — the same pattern covered in automating R-tree index rebuilds after a bulk load.

Verification

The only proof that matters is whether the planner uses the index. Run EXPLAIN QUERY PLAN and look for the R-tree virtual table in the plan:

python
plan = conn.execute(
    "EXPLAIN QUERY PLAN "
    "SELECT ROWID FROM SpatialIndex "
    "WHERE f_table_name = 'parcels' "
    "  AND search_frame = BuildMbr(-1.6, 50.9, -1.4, 51.1, 4326)"
).fetchall()
for step in plan:
    print(step[-1])

A healthy plan mentions idx_parcels_geom (SpatiaLite) or rtree_parcels_geom (GeoPackage). If instead you see SCAN parcels, the index is not being used — the predicate is not expressed as a bounding-box search. You can also confirm the virtual table exists:

sql
-- Either format: confirm the spatial index virtual table was created
SELECT name FROM sqlite_master
WHERE type = 'table'
  AND (name LIKE 'idx_parcels_%' OR name LIKE 'rtree_parcels_%');

Alternative Approaches or Edge Cases

GDAL at write time. If you build the GeoPackage with Fiona or ogr2ogr, passing the SPATIAL_INDEX=YES layer creation option creates the R-tree during the write, so no separate step is needed. This is the least error-prone path when you control the export.

Recovering a stale index. If rows were bulk-loaded with triggers disabled, or the index was created before the data, it can be out of sync. SELECT RecoverSpatialIndex('parcels', 'geom') rebuilds the R-tree from the current geometries without dropping and recreating the virtual table.

Troubleshooting

sqlite3.OperationalError: no such function: CreateSpatialIndex

Cause: mod_spatialite was not loaded on this connection, or extension loading is disabled. Fix: Call conn.enable_load_extension(True) then conn.load_extension("mod_spatialite") before the index SQL — see using sqlite3 with SpatiaLite functions for platform-specific library names.

CreateSpatialIndex returns 0 and no index appears

Cause: The geometry column is not registered in geometry_columns, so SpatiaLite refuses to index it. Fix: Run SELECT RecoverGeometryColumn('parcels', 'geom', 4326, 'POLYGON') to register it, then re-run CreateSpatialIndex.

EXPLAIN QUERY PLAN shows SCAN parcels despite the index existing

Cause: The query filters with a function like ST_Intersects directly instead of a bounding-box search_frame, so the planner cannot use the R-tree. Fix: Add a ROWID IN (SELECT ... FROM SpatialIndex WHERE search_frame = BuildMbr(...)) subquery to pre-filter by bounding box, then apply the exact predicate to the survivors.

Frequently Asked Questions

Does creating a spatial index slow down inserts?

Yes, measurably. The index is kept current by triggers that fire on every insert, update and delete, so each row write also writes an R-tree entry. For interactive editing that cost is invisible; for a bulk load of hundreds of thousands of rows it can double the run time. The standard remedy is to load first and index afterwards — drop or never create the index, insert inside one transaction, then build the R-tree in a single pass over the finished table.

Why does my query ignore the index even though it exists?

Because the predicate is not expressed as a bounding-box search. SQLite’s query planner cannot look inside ST_Intersects(geom, ?) and infer a rectangle from it, so it falls back to scanning. The index is only reachable through an explicit bounding-box filter — a search_frame subquery in SpatiaLite, or a join against the rtree_ table’s minx/maxx/miny/maxy columns in GeoPackage. Write the query as a cheap rectangle filter first and the exact predicate second.

How do I tell whether an existing index is stale?

Compare its entry count with the table’s row count: a healthy index has exactly one entry per non-null geometry. Running SELECT count(*) FROM idx_parcels_geom against SELECT count(*) FROM parcels WHERE geom IS NOT NULL catches the common case of rows loaded with triggers disabled. A matching count can still hide stale extents if geometries were updated in place, so on any container you did not build yourself, run RecoverSpatialIndex before trusting query results.

Can one table have a spatial index on more than one geometry column?

Yes. Both the SpatiaLite and the GeoPackage naming schemes include the geometry column name precisely so a table can carry several — a parcels table might index both a boundary polygon and a centroid point. Each index is a separate virtual table with its own triggers, and each query must name the one it wants; there is no automatic selection between them.