How to Repair Self-Intersecting Polygons with Shapely
Call shapely.make_valid(geom) — it separates the crossing lobes and returns a valid geometry whose area is the same as the input’s, but whose type is usually MultiPolygon rather than Polygon. Handle that type change explicitly before writing back, because a layer declared POLYGON will reject the result and code that reads .exterior will break on it.
This page belongs to the Geometry Validity & Topology Repair guide. Self-intersection is the single most common invalidity in field data, and it is also the one with the cleanest repair — which makes it the right place to start.
Why This Matters
A self-intersecting polygon is a ring that crosses itself: the classic case is the bow-tie, where the vertex order runs to the far corner and back, producing two lobes joined at a crossing point. It arises from digitising with a snap tolerance that jumped a vertex, from a coordinate array assembled in the wrong order, and from a reprojection that moved vertices past one another near the edge of a projection’s usable area.
The consequence is not a crash. GEOS still computes an answer for ST_Intersects and ST_Area on such a geometry; the answers are simply not the ones the polygon appears to represent. Area is the clearest symptom, because the two lobes of a bow-tie have opposite winding and their areas subtract — a shape that looks like it covers two hectares can report a fraction of that, or zero.
Prerequisites
- Python 3.9+ with
shapely2.0+ (make_validis a module-level function in 2.x) - GEOS 3.9+ underneath Shapely — earlier builds have a less predictable repair
- A container with the invalid rows already identified, per How to Find Invalid Geometries in a GeoPackage
- A backup, or a copy of the container to repair into
Primary Method
# Repair a self-intersecting polygon and coerce the result to a typed contract
from shapely import make_valid
from shapely.geometry import MultiPolygon, Polygon
from shapely.geometry.collection import GeometryCollection
def repair_self_intersection(geom, area_tolerance: float = 1e-9):
"""
Return (repaired_geometry, note).
The repaired geometry is always a MultiPolygon so it can be written to a
layer declared MULTIPOLYGON. Returns (None, reason) when the repair would
discard more area than the tolerance allows — those rows need a human.
"""
if geom.is_valid:
return MultiPolygon([geom]) if isinstance(geom, Polygon) else geom, "already valid"
fixed = make_valid(geom)
# A bow-tie repairs cleanly; a degenerate ring can leave lines behind.
if isinstance(fixed, GeometryCollection):
areal = [g for g in fixed.geoms if isinstance(g, (Polygon, MultiPolygon))]
if not areal:
return None, "repair left no area at all"
discarded = fixed.area - sum(g.area for g in areal)
if abs(discarded) > area_tolerance:
return None, f"repair would discard {discarded:.9f} of area"
fixed = MultiPolygon(
[p for g in areal for p in (g.geoms if isinstance(g, MultiPolygon) else [g])]
)
if isinstance(fixed, Polygon):
fixed = MultiPolygon([fixed])
return fixed.normalize(), f"repaired: {len(fixed.geoms)} component(s)"
The area_tolerance check is what separates a repair from a silent deletion. make_valid never invents area, but it can drop degenerate parts that had none to begin with, and confirming that the dropped amount is negligible is the difference between a defensible repair and a data-loss bug.
Step-by-Step Walkthrough
1. Reproduce the failure on a known geometry
Before repairing anything real, confirm the behaviour on a bow-tie you constructed, so you know what “working” looks like.
from shapely.geometry import Polygon
from shapely import make_valid
from shapely.validation import explain_validity
bowtie = Polygon([(0, 0), (2, 2), (2, 0), (0, 2), (0, 0)])
print(bowtie.is_valid) # False
print(explain_validity(bowtie)) # Self-intersection[1 1]
print(bowtie.area) # 0.0 — the lobes cancel
fixed = make_valid(bowtie)
print(fixed.geom_type, len(fixed.geoms), fixed.area) # MultiPolygon 2 2.0
The area values are the point of this exercise. The invalid polygon reports zero area; the repaired one reports two square units. That gap is what makes bow-ties dangerous in an area-based report — they do not merely give a slightly wrong number, they give a plausible small one.
2. Repair one row and inspect the change
from shapely import from_wkb, to_wkb
blob = conn.execute(
"SELECT geom FROM parcels WHERE fid = ?", (target_fid,)
).fetchone()[0]
geom = from_wkb(gpb_to_wkb(blob))
fixed, note = repair_self_intersection(geom)
print(note)
print(f"{geom.geom_type} -> {fixed.geom_type}")
print(f"components: 1 -> {len(fixed.geoms)}")
print(f"vertices: {len(geom.exterior.coords)} -> "
f"{sum(len(g.exterior.coords) for g in fixed.geoms)}")
The vertex count usually rises by one or two, because the crossing point becomes an explicit vertex on both new rings. That is expected and is not a change to the shape.
3. Confirm no vertex moved
The strongest guarantee make_valid gives is that it does not invent coordinates. Asserting that explicitly turns a trust question into a test.
original = set(geom.exterior.coords)
repaired = {c for g in fixed.geoms for c in g.exterior.coords}
introduced = repaired - original
# the crossing point is computed, so at most a couple of new vertices appear
assert len(introduced) <= 2, f"repair introduced {len(introduced)} vertices"
assert original <= repaired | introduced, "repair dropped an original vertex"
4. Repair the layer in one pass
Compute outside the transaction; write inside it. The write lock should be held for the updates only, not for the geometry work.
# Full repair pass over one layer
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
AND ST_IsValidReason(geom) LIKE 'Self-intersection%'
""").fetchall()
updates, review = [], []
for fid, blob in broken:
fixed, note = repair_self_intersection(from_wkb(gpb_to_wkb(blob)))
if fixed is None:
review.append((fid, note))
else:
updates.append((to_wkb(fixed), fid))
conn.execute("BEGIN IMMEDIATE")
conn.executemany(
"UPDATE parcels SET geom = GeomFromWKB(?, 4326) WHERE fid = ?", updates
)
conn.execute("COMMIT")
print(f"repaired {len(updates)}; {len(review)} need review")
for fid, note in review:
print(f" fid={fid}: {note}")
Filtering on the reason string keeps this pass focused. A layer with several kinds of invalidity is better repaired one class at a time, because each class has a different acceptable outcome.
5. Rebuild the spatial index
Every repaired geometry has a new bounding box. Where the index triggers were active the entries updated with the rows; where they were not, the index now describes the pre-repair extents.
-- SpatiaLite: refresh after a geometry-changing update
SELECT RecoverSpatialIndex('parcels', 'geom');
Verification
Three checks close the pass: nothing invalid remains, total area moved as expected, and the index agrees with the data.
-- After the pass
SELECT count(*) AS still_invalid FROM parcels
WHERE geom IS NOT NULL AND ST_IsValid(geom) = 0;
SELECT round(sum(ST_Area(geom)), 6) 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;
Total area is expected to increase after repairing bow-ties, not stay constant — the invalid geometries were under-reporting because their lobes cancelled. An unchanged total is a sign the pass did not actually write, and a decrease is a sign something other than self-intersection was repaired along the way.
Alternative Approaches or Edge Cases
Keeping the largest lobe only. Where a bow-tie is genuinely a digitising slip and one lobe is a tiny artefact, taking the largest component is defensible — but it is a domain decision, not a repair. Make it explicit: max(fixed.geoms, key=lambda g: g.area), with the discarded area logged, so the choice is visible in the record.
Repairing in SQL. SpatiaLite exposes ST_MakeValid, so a whole-layer repair can be a single UPDATE. It is concise and holds the write lock for the entire scan, offers no chance to inspect what changed, and cannot refuse a repair that loses area. Use it for a small layer you own; use the Python pass for anything shipping to the field.
Self-intersection introduced by reprojection. If rows become invalid only after a transformation, the cause is distortion near the edge of the projection’s usable area rather than bad capture. Repairing the reprojected geometry is correct, but check the source too — the same features are probably fine in their original reference system, and reprojecting to a more appropriate target may avoid the problem entirely.
Troubleshooting
make_valid returns the input unchanged
Cause: The geometry was already valid, or the invalidity is of a kind GEOS considers acceptable — ring orientation, for instance, which is a convention rather than a validity rule. Fix: Check explain_validity first; if it reports no problem, the issue lies elsewhere, and the parent guide covers what validity does and does not cover.
ValueError: Record's geometry type does not match collection schema on write
Cause: The layer is declared POLYGON and the repair produced a MultiPolygon. Fix: Either promote the layer’s declared type to MULTIPOLYGON — which is the right answer for a layer that can legitimately contain multipart features — or reject the row for review. Forcing the type by taking one component discards real geometry.
Area increases far more than expected
Cause: Several rows had multiple crossings, so each repaired into three or more lobes whose areas all now add rather than cancel. Fix: This is correct behaviour, but it is worth inspecting the largest changes: a geometry that gains a great deal of area was substantially wrong before, and the underlying capture is probably worth reviewing rather than just repairing.
Frequently Asked Questions
Does the repair preserve attributes?
Yes — nothing in this pass touches non-geometry columns. Each row keeps its identifier and every attribute; only the geometry BLOB is rewritten. That is worth stating because some repair workflows in desktop GIS explode a multipart feature into several rows, which does duplicate attributes. This one does not: one row in, one row out.
Can I repair without loading mod_spatialite?
Yes. The repair itself is pure Shapely, and the only thing the extension provides is the SQL-side ST_IsValid filter and the GeomFromWKB wrapper on write. Both have Python equivalents: test validity with geom.is_valid after decoding each BLOB, and write the GeoPackage Binary header yourself as described in How to Serialize MultiPolygon Geometries to WKB in Python.
Is `make_valid` deterministic across GEOS versions?
The result is a valid geometry covering the same area on every version, but the exact component ordering and vertex sequence changed between GEOS 3.8, 3.9 and 3.11. That matters for tests: assert on area, component count and validity rather than on a serialised form, or the suite becomes a GEOS version detector. Pinning the library, as covered in Testing & CI for Spatial Pipelines, removes the ambiguity entirely.
Related
- Geometry Validity & Topology Repair — parent guide: the full set of validity rules and repair strategies
- How to Find Invalid Geometries in a GeoPackage — producing the work list this page repairs
- How to Serialize MultiPolygon Geometries to WKB in Python — writing the repaired geometry back without a driver
- How to Automate R-tree Index Rebuilds After Bulk Load — the rebuild the repair pass owes the index