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…

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.

RuleWhat it forbidsTypical cause
Ring closureA ring whose first and last vertex differHand-built coordinate arrays
Ring simplicityA ring that crosses itselfDigitising error, or a bow-tie from bad coordinate order
Hole containmentAn interior ring outside, or crossing, its exteriorA hole copied from the wrong feature
Hole nestingInterior rings that overlap one anotherTwo holes digitised over the same area
Component disjointnessMultiPolygon components that overlap in areaA 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.

Five ways a polygon fails OGC validityFive violation classes shown side by side. An unclosed ring has a gap between its first and last vertex. A self-intersecting ring crosses itself, forming a bow-tie. A hole that escapes its exterior ring sits partly outside the polygon. Two overlapping holes cover the same area twice. Two components of a multipolygon overlap in area rather than being disjoint. Only the last two survive a naive visual check.Every real invalid polygon is one of theseunclosed ringfirst vertex ≠ lastusually hand-built arraysmost parsers reject it outrightself-intersectionthe bow-tiedigitising or bad orderingrenders, computes nonsenseescaped holeinterior ring outside exteriorhole taken from another featurearea goes negativeoverlapping holessame area removed twicelooks correct on a maponly area reveals itoverlapping componentsmultipart pieces intersecta merge that never unioneddouble-counts everything
The bottom two render perfectly and are the ones that survive review; the top three usually announce themselves.

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.

python
# 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.

python
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.

What make_valid returns for each class of invalid inputThree outcomes from the same function. A self-intersecting polygon becomes a multipolygon whose components are the separated lobes, so the type changes and the area is preserved. A polygon with an escaped hole becomes a polygon or multipolygon whose area differs from the input, because the invalid interpretation had no well-defined area. A sliver collapses into a geometry collection containing a linestring, because the repaired shape has no area at all.invalid inputwhat make_valid returnsself-intersecting Polygona bow-tieMultiPolygon — type changedarea preservedPolygon with an escaped holehole outside the exteriorvalid geometry — area changedthe input had no defined areazero-width slivercollapsed ringGeometryCollection with a LineStringno area survives
Only the first row is a repair in the intuitive sense. The other two are honest answers to a question the data did not have a good answer to.

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.

python
# 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.

python
# 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.

sql
-- 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.

sql
-- 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.

Three assertions that close out a repair passThree checks run after repair. The invalid count must be zero, which proves the repair reached every row. The total area must match the pre-repair figure within the accepted tolerance, which proves the repair did not silently change what the layer represents. The index entry count must match the non-null geometry count, which proves the spatial index was rebuilt after the geometry changed.1 · invalid count is zeroproves the pass reached every row, not just the ones it sampled2 · total area within toleranceproves the repair did not quietly replace the layer with a different one3 · index rows equal geometry rowsproves the index was rebuilt after every bounding box moved
Only the first is about validity. The other two catch the ways a successful repair can still be wrong.

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:

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.