Fixing Self-Intersecting Parcel Polygons with make_valid
The scenario: an overlay raises TopologyException: Input geom 1 is invalid, someone applies
make_valid to the whole layer, the overlay runs, and the parcel count comes out 12 higher than the
input. A bowtie was repaired into two polygons, each inherited the original parcel identifier, and
the acreage is now double-counted for every one of those parcels. This page repairs geometry without
that outcome, and it sits under
spatial data quality and validation.
Root-cause analysis
A blanket repair fails because “invalid” covers several defects with different correct treatments.
- A bowtie self-intersection. The ring crosses itself once, and
make_validcorrectly returns a MultiPolygon of the two lobes. Correct geometrically, and a data error if the row keeps one identifier — the parcel is now two rows or one multipart geometry, and downstream counts change. - A zero-width spike or a duplicate vertex. Two vertices coincide or a spur doubles back with no
area.
make_validwill handle it, butset_precisionwith a grid size is cheaper, deterministic and does not change the polygon’s structure. - A hole outside its shell. A ring recorded as an interior that lies wholly outside the exterior.
The repair is a ring reassignment — the “hole” is a separate polygon — and
make_validmay discard it entirely, silently losing a parcel. - Ring orientation. Not invalid under the OGC rules that GEOS enforces, but invalid under RFC
7946, which is why a layer that passes
is_validcan still be rejected by a permitting portal.
Pre-flight validation: read the reason before repairing
shapely.validation.explain_validity names the defect and gives its coordinates. Grouping a layer by
reason turns a blanket repair into four targeted ones.
import geopandas as gpd
from shapely.validation import explain_validity
def classify_invalidity(gdf: gpd.GeoDataFrame) -> gpd.pd.DataFrame:
"""Group invalid geometry by the reason GEOS gives, with an example each."""
invalid = gdf[~gdf.is_valid]
if invalid.empty:
return gpd.pd.DataFrame(columns=["reason", "count", "example_index"])
reasons = invalid.geometry.apply(explain_validity)
# The reason string carries coordinates; the kind is the part before the bracket.
kind = reasons.str.split("[").str[0].str.strip()
summary = (
gpd.pd.DataFrame({"kind": kind, "reason": reasons, "idx": invalid.index})
.groupby("kind")
.agg(count=("idx", "size"), example_index=("idx", "first"), example=("reason", "first"))
.reset_index()
.sort_values("count", ascending=False)
)
return summary
Running this first is what distinguishes “412 invalid geometries” from “398 duplicate vertices, 12 bowties and 2 holes outside their shell” — three different repairs with three different risks.
Fix implementation
import geopandas as gpd
import shapely
from shapely.validation import explain_validity, make_valid
def repair_parcels(
gdf: gpd.GeoDataFrame,
*,
id_field: str = "parcel_id",
precision_grid_m: float = 0.001,
area_tolerance: float = 1e-6,
) -> tuple[gpd.GeoDataFrame, gpd.pd.DataFrame]:
"""Repair by reason, keep one row per parcel, and record what each repair moved."""
out = gdf.copy()
log = []
invalid_mask = ~out.is_valid
for idx in out.index[invalid_mask]:
geom = out.at[idx, "geometry"]
reason = explain_validity(geom)
before = geom.area
if "Repeated Point" in reason or "Self-intersection" in reason and geom.is_simple:
# Precision snapping is deterministic and preserves structure.
repaired = shapely.set_precision(geom, precision_grid_m)
else:
repaired = make_valid(geom)
# A repair that returns several polygons must stay ONE row for this parcel.
if repaired.geom_type == "GeometryCollection":
polys = [g for g in repaired.geoms if g.geom_type in ("Polygon", "MultiPolygon")]
repaired = shapely.union_all(polys) if polys else repaired
if repaired.geom_type == "MultiPolygon":
parts = len(repaired.geoms)
else:
parts = 1
out.at[idx, "geometry"] = repaired
log.append({
id_field: out.at[idx, id_field],
"reason": reason.split("[")[0].strip(),
"area_before": before,
"area_after": repaired.area,
"area_delta": repaired.area - before,
"parts_after": parts,
})
report = gpd.pd.DataFrame(log)
if not report.empty:
report["area_delta_pct"] = report["area_delta"] / report["area_before"].replace(0, float("nan")) * 100
return out, report
The two decisions that keep the parcel count stable are collapsing a GeometryCollection to its
polygonal parts and leaving a MultiPolygon as one row. A repair that explodes into several rows is
almost never what a parcel layer wants — the parcel is still one legal object.
Fallback routing and performance tuning
- Prefer
set_precisionwhere it applies. It is deterministic, orders of magnitude cheaper thanmake_validon a large layer, and it cannot restructure a polygon into a collection. - Never use
buffer(0)on a parcel layer. It works, and it silently drops the smaller lobe of a bowtie — which is the one outcome nobody notices until the acreage is challenged. - Repair before the overlay, not inside it. GEOS will raise mid-operation otherwise, and the partial result is discarded, so the whole overlay is repeated.
- Vectorise with
shapely.make_validon the array. Shapely 2 applies it over a GeoSeries without a Python loop, which on a national layer is the difference between minutes and an hour. - Keep the pre-repair geometry. A repair is a modification of source data, and the original is what an auditor asks for when a boundary is disputed.
Downstream validation
def assert_repair_conservative(report, *, max_area_shift_pct: float = 0.5, max_new_parts: int = 1) -> None:
"""A repair should fix topology, not redraw parcels."""
if report.empty:
return
worst = report["area_delta_pct"].abs().max()
assert worst <= max_area_shift_pct, (
f"a repair moved {worst:.3f}% of a parcel's area — inspect before accepting"
)
exploded = report[report["parts_after"] > max_new_parts]
assert exploded.empty, (
f"{len(exploded)} parcels became multipart: {list(exploded['parcel_id'])[:5]} — "
"confirm these are genuinely multipart holdings"
)
What a repair is allowed to change
A useful discipline is to state, before running anything, which properties a repair may alter.
Area may move slightly. Snapping a duplicate vertex to a millimetre grid changes area by a vanishing amount; repairing a bowtie changes it by the overlapping lobe, which can be a real fraction. The first is noise and the second is a fact about the source that deserves a look.
Topology must improve. Every output must be valid, and no repair may introduce an overlap with a neighbouring parcel that was not there before. A repair that fixes one polygon by growing it into its neighbour has traded a validity error for a boundary dispute.
Identity must be preserved. One input row is one output row. Where the repair genuinely produces disjoint parts, they belong in one multipart geometry under the original identifier, not in several rows — unless the parcel truly is several legal parcels, which is a data-modelling decision rather than a repair.
Attributes must not move. It sounds obvious, and the common violation is subtle: a repair implemented as a spatial rebuild that re-joins attributes by position rather than by identifier scrambles them whenever the row order changes.
Writing these four down turns “we repaired the layer” into something a reviewer can check, and the
area_delta column in the report above is the evidence for the first two.
Frequently asked questions
Is make_valid ever the wrong tool?
For a hole recorded outside its shell, yes — it may discard the ring entirely rather than promote it
to its own polygon, which silently loses land. That case wants an explicit ring reassignment. It is
also the wrong tool when set_precision would do, not because it is incorrect but because it can
restructure a polygon that only needed a vertex snapped.
Why did a repaired layer fail a permitting portal that accepts invalid geometry?
Almost certainly ring winding. GEOS validity does not constrain orientation, but RFC 7946 requires exterior rings counter-clockwise and interior rings clockwise, and a strict consumer reads a reversed exterior as the complement of the polygon. Enforce winding at export, as covered in exporting compliance overlay results to GeoJSON.
What precision grid should set_precision use?
One that is finer than the survey tolerance and coarser than floating-point noise — a millimetre in a projected metric frame is a good default. Coarser than a centimetre starts to move real boundaries; finer than a micrometre does not remove the duplicate vertices it was called for.
Should invalid geometry ever be dropped instead of repaired?
Only when the row is meaningless — a zero-area collapse or a geometry outside the study area by hundreds of kilometres. Dropping a repairable parcel removes land from the analysis without saying so, which is the same failure as silently repairing it badly. Quarantine and report is the middle path.
How do I repair a whole national layer efficiently?
Classify first, then apply the cheap repair to the large group and the expensive one to the small
group. On a typical national parcel extract, upwards of 95 percent of invalid geometries are repeated
points that set_precision fixes in one vectorised call, leaving a few hundred genuine
self-intersections for make_valid.
Does repairing change the CRS or the extent?
Neither, and asserting both is a cheap way to catch a repair implemented as an accidental reprojection. Check that the output CRS matches the input and that the total bounds have not moved by more than the precision grid; both are one line and both have caught real bugs.
Related
- Spatial Data Quality & Validation — the parent quality gate
- Validating Geometry Topology with Shapely 2 Predicates — the invalidity reasons this page repairs
- Detecting & Removing Sliver Polygons in GeoPandas — the artefacts a repaired overlay tends to produce
- Best Practices for Cleaning Messy Shapefiles in GeoPandas — the format defects that produce this geometry