Fixing Self-Intersecting Polygons Programmatically in Python
Self-intersecting "bowtie" polygons parse cleanly but crash overlays, corrupt area sums, and fail to render — this guide repairs them deterministically with make_valid while keeping the coordinate reference system honest end to end. It is for anyone loading digitized parcels, floodplain boundaries, or merged administrative layers into a Python pipeline and hitting TopologyException downstream. It sits under Topology Validation & Repair in Geospatial Data Ingestion & Processing Workflows, and pairs with Best Practices for GeoJSON Validation, which catches the same defect at the file boundary.
Why This Approach / What Goes Wrong
A self-intersection occurs when the edges of a single polygon ring cross one another — the classic figure-eight bowtie, but also unclosed rings, spikes, and zero-area slivers born of coordinate truncation. Such geometry violates the OGC Simple Features rule that a polygon boundary must be a simple, non-crossing ring, so GEOS-backed predicates like intersects, union, and area either raise or return silently wrong answers.
Two naive fixes are common and both are traps:
buffer(0)as a catch-all. A zero-width buffer does snap many bowties into valid shapes, but it is a side effect, not a repair contract: it can drop slivers, fracture one polygon into several, and shift area by amounts you never see. Prefer it only as a documented fallback.- Repairing in geographic degrees.
make_validcomputes intersections in Cartesian space. Run it on raw EPSG:4326 longitude/latitude and the split points are placed by treating degrees as a flat plane — unstable near the poles and across the antimeridian, where a degree of longitude is nothing like a degree of latitude.
The correct approach is to project to an appropriate metric CRS — a local UTM zone, never Web Mercator, whose area and distance distortion makes it unfit for any metric operation — run the deterministic make_valid in that planar space, verify, then reproject back to the caller's original CRS. make_valid splits each intersecting edge exactly at its crossing coordinate and returns a valid Polygon or MultiPolygon, which is auditable in a way that buffer(0) is not.
Prerequisites
geopandas>=1.0.0— GeoDataFrame I/O and vectorizedto_crs/is_validshapely>=2.0.0— GEOS-backedmake_valid,explain_validity,set_precisionpyproj>=3.4.0— CRS lookup and the UTM zone estimator used below
python -m pip install "geopandas>=1.0.0" "shapely>=2.0.0" "pyproj>=3.4.0"
Shapely 2.0 is required: the top-level vectorized shapely.make_valid and the stable explain_validity signature used here do not exist in the 1.x API, a break detailed in Shapely 1.x vs Shapely 2.0 Vectorization.
Two later boundaries change what make_valid will accept and what it returns. Shapely 2.1 added the method and keep_collapsed arguments to the top-level function, so shapely.make_valid(geom, method="structure", keep_collapsed=False) is a syntax error on 2.0.x and raises TypeError rather than doing something subtly different — a clean failure, but one that only appears on the machine with the older wheel. Underneath, the "structure" algorithm itself requires GEOS 3.10 or newer; a Shapely 2.1 wheel linked against GEOS 3.8 will import fine and then raise GEOSException at call time. Print both numbers before you rely on either:
import shapely
print(shapely.__version__) # 2.1.2
print(shapely.geos_version) # (3, 13, 1)
Keeping these identical between your workstation and CI matters more here than in most stages, because the shape of the repair output is version-dependent: a bowtie whose two lobes touch only at the crossing point comes back as a MultiPolygon on GEOS 3.12 and can come back as a GeometryCollection on 3.8, which changes how many rows a subsequent explode emits and therefore the row count of the file you ship.
Step-by-Step Implementation
1. Pick a metric CRS from the data, not a constant. Hard-coding one projected CRS distorts geometry that lands outside its zone. Let GeoPandas estimate the UTM zone from the layer's own bounds so the repair runs in a true metric space — the same auto-selection covered in Choosing a UTM Zone Automatically in Python.
import geopandas as gpd
parcels = gpd.read_file("parcels.gpkg")
if parcels.crs is None:
raise ValueError("Layer has no CRS; set one before repair (e.g. parcels.set_crs('EPSG:4326')).")
# estimate_utm_crs returns the metric UTM zone covering the layer's centroid
metric_crs = parcels.estimate_utm_crs() # e.g. EPSG:32633 (UTM 33N)
2. Reproject into that metric CRS. GeoPandas transforms with pyproj under the hood and honours axis order internally, so to_crs needs no always_xy flag — but keep the original CRS to return to.
source_crs = parcels.crs
parcels_utm = parcels.to_crs(metric_crs)
3. Flag the invalid geometries with GEOS. is_valid is vectorized; repairing only the invalid rows leaves clean geometry byte-for-byte untouched and makes the before/after count auditable.
invalid_mask = ~parcels_utm.is_valid
print(f"{invalid_mask.sum()} of {len(parcels_utm)} geometries are invalid.")
4. Repair with vectorized make_valid. Call the top-level Shapely function on the geometry array of the invalid rows. It runs in compiled C, splitting each self-intersection at its exact crossing coordinate.
import shapely
parcels_utm.loc[invalid_mask, "geometry"] = shapely.make_valid(
parcels_utm.loc[invalid_mask, "geometry"].values
)
The default algorithm is "linework", and for a bowtie it does exactly what the figure above shows: it noded the crossing, rebuilds rings from the original edges, and keeps every input coordinate. The alternative, "structure", treats each ring as a description of area rather than a list of edges, and resolves the overlap by taking the symmetric result — which for a true bowtie is the same two lobes, but for the other defects hiding in the same column is not. A polygon with a hole that pokes outside its shell repairs to shell-minus-hole under "structure" and to a two-part MultiPolygon under "linework". A ring with a zero-width spike keeps the spike as a degenerate line under "linework" (inside a GeometryCollection) and drops it entirely under "structure". Neither is correct in the abstract; the question is whether your downstream consumer is a geometry(Polygon) column that will reject a collection, or an audit that must account for every coordinate the surveyor recorded.
from shapely import make_valid
from shapely.geometry import Polygon
# A shell with a spike: the last two vertices retrace the same segment
spiked = Polygon([(0, 0), (10, 0), (10, 10), (5, 10), (5, 16), (5, 10), (0, 10)])
print(make_valid(spiked).geom_type) # GeometryCollection
print(make_valid(spiked, method="structure").geom_type) # Polygon
print(round(make_valid(spiked, method="structure").area, 1)) # 100.0
For cadastral and administrative polygons — anything where the ring is meant to bound a region and slivers are digitising noise — method="structure" with the default keep_collapsed=True is the pragmatic choice, because it returns a single typed polygon that a database column, a tile builder, or a spatial join will accept without a filtering step. Set keep_collapsed=False when a feature that degenerates to a line or a point should disappear rather than survive as a zero-area fragment.
4b. Audit what the repair changed before you accept it. make_valid is deterministic, but "deterministic" is not "harmless". The area of an invalid ring is not a meaningful number to begin with: GEOS evaluates the shoelace formula over whatever vertex sequence it was handed, and in a bowtie the two lobes are traversed in opposite directions, so their contributions partly cancel. A perfectly symmetric bowtie reports an area of zero before repair and its true total afterwards. That is a repair moving the number toward the truth, but it is still a number that changed on a feature someone may have signed off on, so capture the delta per row while both versions are in memory and still in metres.
import pandas as pd
import shapely
before = parcels_utm.loc[invalid_mask, "geometry"].values # captured pre-repair
after = shapely.make_valid(before, method="structure")
report = pd.DataFrame({
"parcel_id": parcels_utm.loc[invalid_mask, "parcel_id"].values,
"type_after": [g.geom_type for g in after],
"area_before_m2": shapely.area(before).round(2),
"area_after_m2": shapely.area(after).round(2),
"area_delta_m2": (shapely.area(after) - shapely.area(before)).round(2),
"vertices_before": shapely.get_num_coordinates(before),
"vertices_after": shapely.get_num_coordinates(after),
})
print(report.sort_values("area_delta_m2", key=abs, ascending=False).head(3))
# parcel_id type_after area_before_m2 area_after_m2 area_delta_m2 ...
# 3 A-4417 MultiPolygon 0.00 8421.55 8421.55
# 7 A-5102 Polygon 3310.08 3310.11 0.03
# 1 A-0088 Polygon 1902.44 1902.44 0.00
Read the tail of that table, not the head. A large positive delta on a MultiPolygon row is a genuine bowtie whose lobes were cancelling — expected, explainable, and worth a line in the changelog. A delta of a few square centimetres on a row that stayed a Polygon is a sliver being cleaned up. What should alarm you is a negative delta of any size on a Polygon row: that means method="structure" decided part of the ring described a hole rather than land, and you have lost real area. Write this frame out beside the cleaned layer; on a cadastral or flood-designation dataset it is the difference between a repair and an unexplained change to a legal boundary.
5. Reproject back and pin precision. Return to the caller's CRS, then snap vertices to a fixed grid so re-truncation cannot reintroduce micro-intersections downstream. set_precision with a metre-scale grid is applied before the final reprojection while units are still metres.
# 1 mm grid in the metric CRS removes floating-point slivers deterministically
parcels_utm["geometry"] = shapely.set_precision(
parcels_utm.geometry.values, grid_size=0.001
)
parcels_fixed = parcels_utm.to_crs(source_crs)
6. Wrap it as a reusable, logged function. A pipeline needs the counts and the failure reasons, not just the output layer.
import logging
import shapely
import geopandas as gpd
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
def repair_bowties(gdf: gpd.GeoDataFrame, grid_size: float = 0.001) -> gpd.GeoDataFrame:
"""Repair self-intersecting polygons in a metric UTM CRS, then reproject back.
Runs make_valid in an estimated UTM zone (not Web Mercator), snaps to a
fixed grid, and returns geometry in the caller's original CRS.
"""
if gdf.crs is None:
raise ValueError("GeoDataFrame must have a defined CRS before repair.")
source_crs = gdf.crs
work = gdf.to_crs(gdf.estimate_utm_crs())
invalid_mask = ~work.is_valid
if not invalid_mask.any():
logging.info("All %d geometries already valid; nothing to repair.", len(work))
return gdf
logging.info("Repairing %d invalid geometries.", int(invalid_mask.sum()))
work.loc[invalid_mask, "geometry"] = shapely.make_valid(
work.loc[invalid_mask, "geometry"].values
)
work["geometry"] = shapely.set_precision(work.geometry.values, grid_size=grid_size)
still_invalid = ~work.is_valid
for idx in work.index[still_invalid]:
logging.warning("Row %s still invalid: %s",
idx, shapely.validation.explain_validity(work.geometry.loc[idx]))
return work.to_crs(source_crs)
Verification
Build a deliberate bowtie, repair it, and assert the result is valid and area-consistent. The bowtie below is the canonical self-intersecting square; make_valid returns a two-triangle MultiPolygon.
import geopandas as gpd
from shapely.geometry import Polygon
# A bowtie: the ring crosses itself between the 2nd and 4th vertices
bowtie = Polygon([(0, 0), (2, 2), (2, 0), (0, 2), (0, 0)])
assert not bowtie.is_valid # GEOS flags the self-intersection
parcels = gpd.GeoDataFrame(
{"parcel_id": ["A-1"]}, geometry=[bowtie], crs="EPSG:32633"
)
fixed = repair_bowties(parcels)
print(fixed.geometry.iloc[0].geom_type) # MultiPolygon
print(fixed.is_valid.all()) # True
print(round(fixed.geometry.iloc[0].area, 3)) # 2.0 (two 1x1 triangles)
assert fixed.is_valid.all()
assert fixed.geometry.iloc[0].geom_type == "MultiPolygon"
Expected console output:
INFO: Repairing 1 invalid geometries.
MultiPolygon
True
2.0
Edge Cases & Debugging
- Repair yields an empty geometry. Degenerate zero-area bowties can collapse to nothing after splitting; drop them explicitly before export with
parcels_fixed = parcels_fixed[~parcels_fixed.geometry.is_empty], then log the removedparcel_idvalues so the loss is traceable. explain_validityfor targeted debugging. When a row survives repair,shapely.validation.explain_validity(geom)returns a string likeSelf-intersection[13.402 52.518]— the coordinate is in the current CRS, so read it in the UTM working frame, not lon/lat.GeometryCollectionafter repair. A polygon tangled with a stray line repairs to a mixedGeometryCollection; keep only the polygonal parts withshapely.get_partsfiltered ongeom_typebefore it reaches a PostGIS typed column or a tile builder.- Web mapping export. Reproject the final layer to EPSG:4326 only as the last step, and pass
always_xy=Trueto any manual pyprojTransformerso longitude/latitude order is not silently swapped — the axis-order trap behind most "my points are in the ocean" reports. buffer(0)fallback changes area. If a shape resistsmake_valid,geom.buffer(0)may close it, but compareareabefore and after and log the delta; never treat it as a silent repair in a pipeline you have to audit.- The repair is slower than the whole rest of the pipeline.
make_validis the most expensive operation in this stage — roughly two orders of magnitude dearer per geometry thanis_valid— because it has to node every edge against every other edge in the ring, which is superlinear in vertex count. Repairing only the rows theis_validmask flagged, as in step 3, is not a stylistic choice; on a layer where 0.2 % of features are broken it is the difference between seconds and an hour. Never callmake_validunconditionally over a whole column. explain_validityin a loop dominates the runtime. It is a per-geometry Python call with no vectorized form on Shapely 2.0, so calling it on a million rows to build a diagnostic column costs far more than the repair did. Call it only on the residual rows that survived repair, which should number in the tens.- A row is valid but its area is absurd. Validity says nothing about magnitude. A parcel of 4 × 10¹⁰ m² is a coordinate in the wrong CRS, not a topology defect, and no amount of
make_validwill fix it — checkparcels_utm.area.describe()against the plausible range for your feature class as a separate gate. - Repair reintroduces invalidity after
to_crs. Reprojection is a nonlinear warp: a long straight edge in UTM becomes a slight curve in another system, and two edges that merely touched can end up crossing. If a downstream step reprojects, re-runis_validafter it rather than trusting the gate you passed before it. - Z coordinates disappear. GEOS carries Z through some operations and drops it in others, and
make_validis one of the ones that does not preserve it reliably. If elevation matters, extract Z into an attribute column before the repair rather than expecting the geometry to keep it.
Frequently Asked Questions
Should I use make_valid or buffer(0)?
Use make_valid. buffer(0) works by constructing a zero-width offset curve and returning whatever the offsetting machinery produces, which happens to be valid — the validity is a by-product, not the contract, and nothing in the API tells you what it discarded. It routinely deletes zero-area slivers and can drop an entire lobe of a bowtie without a warning. make_valid has a documented behaviour per algorithm, splits at the exact crossing coordinate, and lets you diff the input against the output as shown in the audit step. The only reason to keep buffer(0) in a modern codebase is as an explicitly logged last resort for a geometry that make_valid returns empty for.
Which algorithm should I pass — linework or structure?
Pick by what consumes the output. If the next stop is a typed database column, a vector-tile build, or anything that expects one polygon per row, use method="structure": it returns polygonal output and drops the degenerate lines and points that would otherwise arrive inside a GeometryCollection. If the next stop is an audit, a conflation against the original survey, or any process that must account for every recorded coordinate, use the default "linework", which preserves all input vertices and merely re-organises them into valid rings. When in doubt, run both on the invalid subset and compare the area report — a large disagreement between the two is itself a signal that the feature needs a human.
Why repair in UTM rather than just leaving the data in EPSG:4326?
Because every decision make_valid makes is a planar computation. It solves line-segment intersections, compares areas to decide which side of a ring is inside, and snaps coordinates that fall within its precision model — all in a flat Cartesian space that assumes the two axes carry the same unit. In degrees they do not: near 60° latitude a degree of longitude is half a degree of latitude on the ground, so a defect that is symmetric in reality is skewed in the coordinate space where the repair is computed, and the crossing point GEOS finds is not the one on the ground. It also makes every tolerance meaningless, which is the specific reason set_precision in step 5 runs before the reprojection back. The zone selection itself is automatic, so there is no cost to doing it correctly.
Can I skip set_precision if make_valid already returned valid geometry?
You can, and many pipelines do, but you are leaving the door open for the defect to come back. A geometry that is valid at full float64 precision can become invalid the moment anything rounds it — a Shapefile write that truncates, a GeoJSON export at six decimal places, a tile builder quantising to integer tile coordinates, or a reprojection. Snapping to an explicit grid while you are still in metres means you have already chosen where the rounding happens and verified that the result survives it. On a layer that never leaves GeoPackage or GeoParquet at full precision, the step is optional; on anything destined for a web map, it is not.
Some geometries are still invalid after the repair. What now?
That is rare, and it almost always means the input was not what you think. Run shapely.validation.explain_validity on the survivors and read the coordinate it reports, remembering that it is in the working CRS — a coordinate near 691240, 5334100 is UTM metres, one near 13.4, 52.5 means the reprojection never happened. The two common causes are a geometry that was already a GeometryCollection before repair, which make_valid will not flatten for you, and rings whose coordinates contain NaN, which no repair can resolve. Quarantine those rows to a separate file with their identifiers rather than dropping them, and gate the export on the count of quarantined rows as described on the parent topic.
Does repairing polygons fix gaps and overlaps between neighbouring features?
No, and this is the most common misunderstanding of the validity gate. is_valid is evaluated on one geometry at a time and knows nothing about the other rows in the table, so a layer in which every polygon is individually perfect can still have hairline gaps along shared borders and strips claimed by two features at once. Those are coverage defects, they will pass this gate untouched, and they need a different tool — see Snapping and Simplifying Polygons Without Creating Gaps.