Geometry Validity & Topology Repair in Spatial SQLite
A spatial container will store an invalid geometry without complaint. INSERT succeeds, the row count goes up, the layer opens in a viewer, and the polygon renders — and then ST_Intersects returns an answer nobody can reproduce, an area calculation comes back negative, and a spatial join produces matches that do not exist on the ground. Nothing raised, because storing bytes and reasoning about topology are different jobs, and only the second one has an opinion about whether a ring may cross itself.
This guide is part of the Core Architecture & Format Standards for Spatial SQLite section. It covers what the OGC Simple Features rules actually require, how to find the rows that break them, and how to repair a layer without quietly changing what it represents — which is the part that makes repair harder than detection.
Prerequisites
Concept & Specification Reference
OGC validity is a small set of rules, and almost every real-world violation is one of five things. The rules apply per geometry, and a MultiPolygon is invalid if any component is.
| Rule | What it forbids | Typical cause |
|---|---|---|
| Ring closure | A ring whose first and last vertex differ | Hand-built coordinate arrays |
| Ring simplicity | A ring that crosses itself | Digitising error, or a bow-tie from bad coordinate order |
| Hole containment | An interior ring outside, or crossing, its exterior | A hole copied from the wrong feature |
| Hole nesting | Interior rings that overlap one another | Two holes digitised over the same area |
| Component disjointness | MultiPolygon components that overlap in area | A merge that unioned nothing |
Two things are not validity rules, and confusing them with validity causes a lot of unnecessary repair work. Ring orientation — exterior counter-clockwise, interior clockwise — is a convention the specification states, and GEOS-based predicates tolerate its violation even though other consumers do not. Coordinate precision is not a validity concern at all: two vertices a nanometre apart are perfectly valid and will still ruin a spatial join.
Step-by-Step Implementation
1. Count the damage before touching anything
Repair decisions depend on scale. A layer with three invalid rows out of forty thousand is a data-entry problem; one with four thousand is a process problem, and repairing the rows without fixing the process means doing it again next month.
# SpatiaLite: count invalid geometries and group them by reason
import sqlite3
conn = sqlite3.connect("field.gpkg")
conn.enable_load_extension(True)
conn.load_extension("mod_spatialite")
rows = conn.execute("""
SELECT ST_IsValidReason(geom) AS reason, count(*) AS n
FROM parcels
WHERE geom IS NOT NULL AND ST_IsValid(geom) = 0
GROUP BY reason
ORDER BY n DESC
""").fetchall()
for reason, n in rows:
print(f"{n:>6} {reason}")
ST_IsValidReason returns a human-readable string that names the failure and, usefully, the coordinate where GEOS found it. Grouping by that string turns an opaque count into a list of distinct problems, which is normally short — a broken pipeline produces one kind of invalidity many thousands of times, not thousands of different ones.
2. Decide what “repaired” is allowed to mean
This is the step people skip, and it is where repairs go wrong. make_valid is not a single operation; it is a family of outcomes, and some of them change the geometry’s type or its area.
from shapely import from_wkb, make_valid
geom = from_wkb(blob)
fixed = make_valid(geom)
print(geom.geom_type, "->", fixed.geom_type)
print(f"area {geom.area:.4f} -> {fixed.area:.4f}")
A bow-tie polygon repairs into a MultiPolygon of two triangles, halving nothing but changing the type. A polygon with an escaped hole repairs into something whose area differs from both the naive interpretation and the intended one. A near-degenerate sliver can repair into a GeometryCollection containing a LineString. All three are legitimate outputs of a correct repair, and all three will break downstream code that assumed a Polygon.
3. Repair with an explicit type contract
Because the output type can change, a repair that writes back into a typed layer needs to say what it will accept. The pattern below repairs, coerces back to the layer’s declared type where that is meaningful, and refuses rather than guessing where it is not.
# Repair a geometry to a declared target type, or reject it for review
from shapely import from_wkb, to_wkb, make_valid
from shapely.geometry import MultiPolygon, Polygon
from shapely.geometry.collection import GeometryCollection
def repair_to_multipolygon(blob: bytes, area_tolerance: float = 1e-9):
"""
Return (wkb, note). wkb is None when the geometry cannot be
represented as a MultiPolygon without discarding area.
"""
geom = from_wkb(blob)
if geom.is_valid:
return to_wkb(geom.normalize()), "already valid"
fixed = make_valid(geom)
# A collection may hold lines or points left over from collapsed rings.
if isinstance(fixed, GeometryCollection):
polys = [g for g in fixed.geoms if isinstance(g, (Polygon, MultiPolygon))]
if not polys:
return None, "repaired to no area — review by hand"
dropped = fixed.area - sum(p.area for p in polys)
if abs(dropped) > area_tolerance:
return None, f"repair would discard {dropped:.6f} of area"
fixed = MultiPolygon(
[p for g in polys for p in (g.geoms if isinstance(g, MultiPolygon) else [g])]
)
if isinstance(fixed, Polygon):
fixed = MultiPolygon([fixed])
return to_wkb(fixed.normalize()), f"repaired ({geom.geom_type} -> MultiPolygon)"
The area_tolerance guard is what stops this being a silent data-loss routine. Anything the repair could not represent as area is compared against a threshold, and a geometry that would lose more than that is returned for review rather than written back.
4. Apply the repair inside one transaction
Repair is a bulk update, which means it is a write-lock problem as much as a geometry problem. Do the geometry work first, outside the transaction, and let the transaction contain only the updates.
# Repair pass: compute outside the transaction, write inside it
import sqlite3
conn = sqlite3.connect("field.gpkg", isolation_level=None)
conn.enable_load_extension(True)
conn.load_extension("mod_spatialite")
broken = conn.execute(
"SELECT fid, geom FROM parcels WHERE geom IS NOT NULL AND ST_IsValid(geom) = 0"
).fetchall()
updates, review = [], []
for fid, blob in broken:
wkb, note = repair_to_multipolygon(blob)
(updates if wkb else review).append((fid, wkb or note))
conn.execute("BEGIN IMMEDIATE")
conn.executemany(
"UPDATE parcels SET geom = GeomFromWKB(?, 4326) WHERE fid = ?",
[(wkb, fid) for fid, wkb in updates],
)
conn.execute("COMMIT")
print(f"repaired {len(updates)}, flagged {len(review)} for review")
5. Rebuild the spatial index
Every repaired geometry has a new bounding box, and an UPDATE that changes geometry changes the extent the index recorded. Where the index is maintained by triggers this happens automatically; where the triggers were dropped for the bulk update, it does not. Rebuild afterwards, following the procedure in How to Automate R-tree Index Rebuilds After Bulk Load.
-- SpatiaLite: refresh the index after a geometry-changing update
SELECT RecoverSpatialIndex('parcels', 'geom');
SELECT UpdateLayerStatistics('parcels', 'geom');
Validation & Verification
A repair pass is only finished when three things are true: no invalid rows remain, the total area moved by no more than the tolerance you accepted, and the index agrees with the data.
-- SpatiaLite: the three assertions a repair pass must satisfy
SELECT count(*) AS still_invalid
FROM parcels WHERE geom IS NOT NULL AND ST_IsValid(geom) = 0;
SELECT round(sum(ST_Area(geom)), 4) AS total_area FROM parcels;
SELECT (SELECT count(*) FROM idx_parcels_geom) AS index_rows,
(SELECT count(*) FROM parcels WHERE geom IS NOT NULL) AS geom_rows;
Record the total area before the repair and compare it afterwards. A repair that fixes every row and changes total area by three per cent has not repaired the layer — it has replaced it with a different one, and the difference is worth understanding before it ships.
Common Failure Modes & Fixes
make_valid returns a GeometryCollection and the write fails
Diagnosis: The layer is typed MULTIPOLYGON and the repair produced a collection containing lines or points left over from collapsed rings. Fix: Filter the collection to its area components and check what was discarded, as in step 3. Writing the collection into a typed layer is not an option — the driver will reject it, and forcing the type by taking geoms[0] discards real geometry.
Total area changes after repair
Diagnosis: Some inputs were invalid in a way that had no well-defined area to preserve — most often an escaped or overlapping hole, where the “intended” area depends on an interpretation the data does not record. Fix: Compare per-row rather than in aggregate, sort by the size of the change, and inspect the largest few. In practice a handful of rows account for nearly all of it, and they usually turn out to have been digitised wrongly rather than encoded wrongly.
Repair succeeds and spatial queries still return the wrong rows
Diagnosis: The R-tree still holds the pre-repair bounding boxes, so the index and the geometry disagree about where features are. Fix: Rebuild the index. This failure is particularly confusing because the row count matches — the entries exist, they simply describe the old extents.
Rows become invalid again after the next sync
Diagnosis: The repair fixed the container and not the producer. Whatever wrote the invalid geometry — a mobile app with a permissive digitising tool, an import that skipped normalisation — is still running. Fix: Move the validity check upstream into the ingest path, so invalid geometry is rejected or repaired on the way in, and keep the container-level pass as a backstop rather than as the mechanism.
ST_IsValid returns NULL rather than 0 or 1
Diagnosis: The geometry is NULL, or the value in the column is not a geometry at all — commonly a text value stored where a BLOB was expected, which SQLite permits. Fix: Filter geom IS NOT NULL explicitly and check the storage class with typeof(geom); anything other than blob needs fixing at the insert path, as covered in Spatial Data Serialization Patterns.
Performance Notes
ST_IsValid decodes the geometry and runs a full topology check, so a validity scan over a large polygon layer is one of the more expensive things you can ask a spatial container to do — comparable to a spatial join and considerably worse than a bounding-box query. Three things make it tolerable.
Scan incrementally where you can. If the layer carries a modification timestamp or a change-log version, validate only rows above the last checked watermark; a nightly pass over the day’s edits costs a fraction of a full sweep and catches the same problems a day earlier.
Do not pair the scan with a repair in one statement. UPDATE parcels SET geom = ST_MakeValid(geom) WHERE ST_IsValid(geom) = 0 looks efficient and is the worst option available: it holds the write lock for the entire scan, offers no opportunity to inspect what changed, and cannot refuse a repair that would discard area.
Finally, remember that the repair itself is usually cheap and the index rebuild afterwards is not. On a large layer, the rebuild dominates the run, which is an argument for batching repairs — accumulating a week of flagged rows and repairing them in one pass costs one rebuild rather than seven.
Child Pages
Pages in this section go deeper on individual detection and repair tasks:
- How to Find Invalid Geometries in a GeoPackage — the scan itself, with and without
mod_spatialite - How to Repair Self-Intersecting Polygons with Shapely — the most common single failure, end to end
- Fixing Ring Orientation Across a Whole Layer — a convention rather than a validity rule, and why it still matters
- Detecting and Removing Duplicate Vertices — the near-degenerate case that validity checks pass
- Snapping Tolerances for Topology Cleanup — choosing a tolerance that closes gaps without merging neighbours
- Validating Geometry Before a Field Sync — moving the check upstream into the pipeline
Frequently Asked Questions
Is `buffer(0)` still a reasonable repair?
It works for a narrow class of input and fails silently outside it, which is why it has largely been retired. Buffering by zero re-runs the geometry through the overlay engine, which happens to resolve simple self-intersections — but it also drops components whose area is below the working precision, and it returns a Polygon where the correct answer was a MultiPolygon. make_valid handles the same cases and reports honestly on the ones buffer(0) quietly discarded. Treat buffer(0) as legacy advice from before make_valid existed.
Should I validate on write or on read?
On write, and then again as a periodic backstop. Validating on write keeps the container clean and puts the error next to the code that produced it, which is where it is cheapest to fix. Validating on read is tempting because it needs no change to the ingest path, but it means every consumer pays the topology check on every query and each of them has to decide independently what to do about a failure. The periodic backstop exists because containers acquire geometry from routes nobody anticipated — a manual edit in a desktop GIS, a merge from a partner’s extract.
Does an invalid geometry break the spatial index?
No — the index stores bounding boxes, and an invalid polygon still has a perfectly well-defined bounding box. That is precisely why invalid geometry survives so long undetected: the index-backed part of a query behaves normally, and only the exact predicate applied to the survivors misbehaves. A query that never gets past the bounding-box stage will never reveal the problem.
Can a `LineString` or `Point` be invalid?
Points cannot, beyond carrying a non-finite coordinate. Lines can, in one narrow sense: a LineString with fewer than two distinct vertices is degenerate, and a MultiLineString is valid even when its components cross, because self-intersection is only a violation for areal geometry. In practice validity work on a spatial container is polygon work, and the effort is best spent there.
How do I stop a repair from moving parcel boundaries?
By comparing per-feature rather than in aggregate, and by rejecting rather than repairing where the change exceeds a threshold you set. make_valid never moves a vertex — every coordinate in the output was present in the input — so a repair cannot shift a boundary. What it can do is reinterpret which side of a ring is inside, and that changes area without moving anything. The area comparison in step 3 is what catches it.
Should the repair run on the device or in the pipeline?
In the pipeline, almost always. Repair is a full-layer operation with an index rebuild attached, which is exactly the kind of work a field device is worst at and a build machine is fine with. The device’s job is to avoid creating invalid geometry — a digitising tool that closes rings and rejects self-intersection at capture time removes the problem at source, and costs far less than repairing it later. Where a device must repair, scope it to the rows it just captured rather than to the layer.
Related
- Core Architecture & Format Standards for Spatial SQLite — parent section: file structure, specification contracts and the registry model
- GeoPackage Specification Deep Dive — what the format requires of geometry beyond validity
- Spatial Data Serialization Patterns — normalising and encoding geometry on the way in, which is where most invalidity is preventable
- How to Automate R-tree Index Rebuilds After Bulk Load — the rebuild every repair pass owes the index