Alerting on Stale Spatial Indexes
Compare the index’s entry count against the layer’s non-null geometry count, then compare a sample of stored extents against the geometry they claim to describe. The first check is one query and catches a missing rebuild; the second is the only thing that catches an index whose counts match and whose extents are wrong, which is what an in-place geometry update leaves behind.
This page belongs to the Data Quality Gates & Monitoring guide.
Why This Matters
A stale spatial index is the only defect on this site that makes a correct query return a wrong answer with no error, no warning and no visible symptom in the container. Every structural check passes. Conformance passes. Feature counts are right. The schema is unchanged. And a bounding-box query silently omits rows, because the index it consulted does not describe where the features actually are.
Two things produce it. A bulk load with the triggers dropped writes rows and no index entries, which the count check catches. An in-place geometry update — a repair pass, a reprojection, a snap — changes bounding boxes while the entry count stays identical, which only an extent comparison catches.
Prerequisites
- A container with a spatial index, per How to Create a Spatial Index in SQLite with Python
mod_spatialitefor the extent comparison; the count check needs nothing- Knowledge of which index naming the container uses — the two formats differ
- A place to run the check: a gate, a nightly job, or both
Primary Method
# stale_index.py — the two checks, in increasing order of cost
import sqlite3
def index_health(path: str) -> list[dict]:
"""One report per indexed layer: entry count, geometry count, extent drift."""
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
conn.enable_load_extension(True)
conn.load_extension("mod_spatialite")
layers = conn.execute("""
SELECT c.table_name, g.column_name
FROM gpkg_contents c
JOIN gpkg_geometry_columns g ON g.table_name = c.table_name
WHERE c.data_type = 'features'
""").fetchall()
out = []
for table, geom in layers:
rtree = f"rtree_{table}_{geom}"
exists = conn.execute(
"SELECT count(*) FROM sqlite_master WHERE name = ?", (rtree,)
).fetchone()[0]
if not exists:
out.append({"layer": table, "state": "no index"})
continue
entries = conn.execute(f'SELECT count(*) FROM "{rtree}"').fetchone()[0]
geoms = conn.execute(
f'SELECT count(*) FROM "{table}" WHERE "{geom}" IS NOT NULL'
).fetchone()[0]
# The cheap check: do the counts agree?
if entries != geoms:
out.append({"layer": table, "state": "count mismatch",
"entries": entries, "geometries": geoms})
continue
# The check that matters: do the stored extents match the geometry?
drifted = conn.execute(f"""
SELECT count(*) FROM "{table}" t
JOIN "{rtree}" r ON r.id = t.rowid
WHERE t."{geom}" IS NOT NULL
AND (abs(r.minx - ST_MinX(t."{geom}")) > 1e-9
OR abs(r.maxx - ST_MaxX(t."{geom}")) > 1e-9
OR abs(r.miny - ST_MinY(t."{geom}")) > 1e-9
OR abs(r.maxy - ST_MaxY(t."{geom}")) > 1e-9)
""").fetchone()[0]
out.append({
"layer": table,
"state": "stale extents" if drifted else "healthy",
"entries": entries, "drifted": drifted,
})
conn.close()
return out
The extent comparison uses ST_MinX and its siblings, which read the geometry header rather than decoding coordinates — so despite being a join across the whole layer it is far cheaper than a validity scan.
Step-by-Step Walkthrough
1. Handle both index naming schemes
GeoPackage names the R-tree rtree_<table>_<geom>; SpatiaLite names it idx_<table>_<geom>. A container can carry either, and code that assumes one silently reports “no index” on the other.
def rtree_name(conn, table: str, geom: str) -> str | None:
for candidate in (f"rtree_{table}_{geom}", f"idx_{table}_{geom}"):
found = conn.execute(
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name = ?",
(candidate,),
).fetchone()[0]
if found:
return candidate
return None
The column names differ too: the GeoPackage R-tree uses minx/maxx/miny/maxy, while SpatiaLite’s uses xmin/xmax/ymin/ymax. Detecting the flavour once and selecting the column names from it keeps the comparison query correct on both.
2. Report per layer, with a state rather than a boolean
for report in index_health("field.gpkg"):
layer, state = report["layer"], report["state"]
if state == "healthy":
continue
if state == "no index":
print(f"WARN {layer}: no spatial index")
elif state == "count mismatch":
print(f"ALERT {layer}: {report['entries']} entries, "
f"{report['geometries']} geometries")
elif state == "stale extents":
print(f"ALERT {layer}: {report['drifted']} entries describe the wrong extent")
Four states, not two. “No index” is a design choice a container may legitimately have made; the other two are defects, and they have different causes worth naming separately in the alert.
3. Rebuild unconditionally when either fires
Both failure modes have the same fix, and the fix is cheap relative to diagnosing which one occurred.
-- SpatiaLite: rebuild from the current geometry, without dropping the virtual table
SELECT RecoverSpatialIndex('parcels', 'geom');
SELECT UpdateLayerStatistics('parcels', 'geom');
-- GeoPackage: repopulate the R-tree from the feature table
DELETE FROM rtree_parcels_geom;
INSERT INTO rtree_parcels_geom (id, minx, maxx, miny, maxy)
SELECT fid, ST_MinX(geom), ST_MaxX(geom), ST_MinY(geom), ST_MaxY(geom)
FROM parcels WHERE geom IS NOT NULL;
Recovery rather than drop-and-recreate keeps the triggers in place, which matters: dropping the virtual table also drops the triggers that keep it current, and recreating one without the other leaves a container that looks healthy today and diverges tomorrow.
4. Run the check where it will actually catch things
Three placements, and each catches something the others do not. In the publication gate, it stops a stale index shipping. After any pass that rewrites geometry — repair, reprojection, snapping — it catches the case immediately rather than a day later. Against a copy of a live container on a schedule, it catches an index that went stale through a route nobody anticipated, such as a manual edit in a desktop tool.
# In the gate, after the geometry-modifying stages
python3 stale_index.py "$TMP" || fail "spatial index is stale"
5. Watch the ratio over time
A count mismatch is binary, but the proportion of drifted extents is a useful trend. A layer where a small and stable fraction drifts each cycle points at a specific stage that is not rebuilding; one where the fraction grows points at a trigger that has been dropped and never restored.
manifest["index_health"] = {
r["layer"]: {
"state": r["state"],
"drifted_fraction": (r.get("drifted", 0) / max(r.get("entries", 1), 1)),
}
for r in index_health(container_path)
}
Verification
The check must be shown to detect both failure modes.
# Mode 1: delete index entries, leaving the geometry alone
import shutil, sqlite3
shutil.copy("good.gpkg", "missing.gpkg")
conn = sqlite3.connect("missing.gpkg")
conn.execute("DELETE FROM rtree_parcels_geom WHERE id % 3 = 0")
conn.commit(); conn.close()
states = {r["layer"]: r["state"] for r in index_health("missing.gpkg")}
assert states["parcels"] == "count mismatch", states
# Mode 2: move the geometry without touching the index — counts stay equal
shutil.copy("good.gpkg", "drifted.gpkg")
conn = sqlite3.connect("drifted.gpkg")
conn.enable_load_extension(True); conn.load_extension("mod_spatialite")
conn.execute("DROP TRIGGER IF EXISTS rtree_parcels_geom_update1")
conn.execute(
"UPDATE parcels SET geom = ST_Translate(geom, 1000, 1000) WHERE fid <= 5"
)
conn.commit(); conn.close()
states = {r["layer"]: r["state"] for r in index_health("drifted.gpkg")}
assert states["parcels"] == "stale extents", states
print("both failure modes detected")
The second test is the important one, because a check that only compares counts passes it — and passing it is exactly the failure this page exists to prevent.
Alternative Approaches or Edge Cases
Sampling instead of a full join. On a very large layer the extent comparison reads every row’s geometry header. Sampling a few thousand rows catches a systematic problem — which is what these failures are — at a fraction of the cost, and a full check on release builds covers the rest. Say which mode ran in the output, or a sampled check reported as a full one is worse than none.
Layers with no index by design. A small lookup layer may legitimately have none, and reporting it as a defect on every run trains people to ignore the output. Record the intended state per layer in the manifest and compare against that rather than assuming every feature layer should be indexed.
Verifying the planner as well. An index that is present, current and never used is a different problem with the same symptom of slow queries. Asserting the query plan, as covered in How to Benchmark a Spatial Query Regression, is the complementary check.
Troubleshooting
Counts match but queries still miss rows
Cause: Stale extents — the entries exist and describe where the features used to be. Fix: Run the extent comparison; this is precisely the case the count check cannot see.
The check reports “no index” on a container that has one
Cause: The other naming scheme. A SpatiaLite container’s index is idx_<table>_<geom>, not rtree_<table>_<geom>. Fix: Detect both, as in step 1, and remember the extent column names differ too.
The index goes stale again after a rebuild
Cause: The triggers were dropped, most likely by a drop-and-recreate rather than a recovery rebuild. Fix: Use the recovery function, and verify the triggers exist afterwards with a query against sqlite_master for type='trigger' and a name matching the index.
Frequently Asked Questions
How expensive is the extent comparison?
Far cheaper than it looks. ST_MinX and its siblings read the bounding box from the geometry header rather than decoding coordinates, so the join costs roughly a table scan of headers rather than a full geometry decode. On a layer where a validity scan takes minutes, this takes seconds.
Should a stale index block publication?
Yes, because it produces wrong answers rather than slow ones. A container with no index is slow and correct; one with a stale index is fast and wrong, and a field user has no way to tell. It is also trivially fixable at the point of detection, which makes blocking cheap.
Does this apply to tile tables?
No. Tile tables are addressed by an exact primary key on zoom, column and row, and no R-tree is involved — there is nothing to go stale. The whole class of problem is specific to feature tables with a spatial index.
Related
- Data Quality Gates & Monitoring — parent guide: where this check sits among the bands
- How to Automate R-tree Index Rebuilds After Bulk Load — the rebuild whose absence this detects
- How to Benchmark a Spatial Query Regression — the complementary check, on whether the index is used at all
- Geometry Validity & Topology Repair — a common source of the in-place geometry updates that cause extent drift