How to Repair Self-Intersecting Polygons with Shapely

Call shapely.makevalid(geom) — it separates the crossing lobes and returns a valid geometry whose area is the same as the input's, but whose type is…

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 shapely 2.0+ (make_valid is 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

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

What make_valid does to a bow-tie polygonA single polygon ring that crosses itself has two lobes meeting at the crossing point. Repair splits it at that point into two separate rings and returns them as a multipolygon. Every vertex in the output was present in the input, and the total area is unchanged, but the geometry type has changed from Polygon to MultiPolygon and the component count has gone from one to two.before — one invalid ringPolygon, 1 exterior ringcrosses itself oncelobe areas subtractST_IsValid returns 0after — two valid ringsMultiPolygon, 2 componentssplit at the crossing pointareas now addST_IsValid returns 1no vertex moves — only the ring structure changeswhich is why the repair cannot shift a boundary
The type change is the part that breaks downstream code, and it is unavoidable: two disjoint lobes cannot be one polygon.

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.

python
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

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

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

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

Repair pass with a review branchRows whose validity reason names self-intersection are read out of the layer. Each is repaired, and the result is checked for discarded area. Rows that repair cleanly go into a batch of updates applied in one transaction. Rows whose repair would discard area are diverted to a review list and left untouched, so no geometry is silently lost.select by reasonself-intersection onlyrepair + area checkoutside the transactionclean — batch updateone transaction, at the endarea would be lostleft untouched, listed
Rows that cannot be repaired without loss are left exactly as they were, so a failed repair is never worse than no repair.

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.

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

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

Why total area rises after repairing bow-tiesThe two lobes of a bow-tie wind in opposite directions, so their signed areas subtract and the polygon reports far less area than it appears to cover — sometimes zero. After repair the lobes are separate rings that both wind conventionally, so their areas add. A repair pass over a layer of bow-ties therefore increases the reported total, and an unchanged total means the pass did not write.before repairtwo lobes, opposite windingsigned areas subtractreported area: too low, or zeroplausible, and wrongafter repairtwo rings, both conventionalareas addreported area: correcttotal for the layer risesan unchanged total after the pass means nothing was written
This is the opposite of a validity repair on other defects, where the total is expected to stay put.

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.