Fixing Invalid Parcel Polygons with make_valid

A parcel with a self-intersecting ring returns an area, a buffer and an intersection without complaint, and all three are wrong. This guide repairs a parcel fabric with Shapely’s make_valid, handles the collection it sometimes returns, measures how much area each repair moved, and routes the parcels it moved too far to review — the operational core of geometry validation and topology repair.

Prerequisites

Step-by-step

Step 1: Find and classify what is actually invalid

Repair only what fails, and know what failed. GEOS reports a reason, and the distribution of reasons usually points at a single upstream cause worth fixing at source.

import geopandas as gpd
from shapely.validation import explain_validity

parcels = gpd.read_file("parcels.gpkg")           # already in the working frame
invalid = parcels[~parcels.geometry.is_valid].copy()
invalid["reason"] = invalid.geometry.map(explain_validity)

print(f"{len(invalid)} of {len(parcels)} invalid "
      f"({len(invalid) / len(parcels):.2%})")
print(invalid["reason"].str.split("[").str[0].str.strip().value_counts())

A fabric where 0.3% of parcels are invalid is ordinary. One where 4% are, all with the same reason, is an export setting somebody can change — a far better outcome than repairing the same defect every refresh.

What GEOS reports, and what each defect breaksSelf-intersection, ring orientation, hole outside shell and duplicate vertices, with the downstream operation each one corrupts.BreaksUsually comes fromSelf-intersectionArea, buffers, overlays — silentlyCAD export, mis-ordered vertexHole outside shellInterior tests invertRing assembled in the wrong orderRing orientationHoles behave as solidA writer that ignores windingDuplicate verticesSome buffer implementationsEditing tools; harmless to area
A fabric where one reason dominates usually has one upstream cause — which is worth fixing at source rather than repairing weekly.

Step 2: Snap to a precision grid first

Most invalidity in parcel data is arithmetic rather than real: vertices that differ in the eighth decimal place, duplicated by an editing tool. Snapping collapses those before repair, which makes the repair both smaller and deterministic.

from shapely import set_precision

GRID = 0.01          # working-frame units — hundredths of a foot, far below survey accuracy

snapped = parcels.copy()
snapped["geometry"] = snapped.geometry.map(lambda g: set_precision(g, GRID))

Snap before validating and snap once. Snapping after repair can reintroduce the defect make_valid just resolved, and repeated snapping at different grids accumulates drift.

Step 3: Repair, and keep only the polygonal result

make_valid resolves a bow tie into whatever geometry is actually implied, which is frequently a GeometryCollection containing polygons plus the lines where the ring crossed itself. Keeping the whole collection breaks every later area computation; taking the largest part silently discards area.

from shapely import make_valid
from shapely.geometry import MultiPolygon
from shapely.geometry.base import BaseMultipartGeometry

def polygonal_only(geom):
    """make_valid can return a collection; keep every polygon, drop lineal debris."""
    fixed = make_valid(geom)
    if fixed.geom_type in ("Polygon", "MultiPolygon"):
        return fixed
    parts = [g for g in getattr(fixed, "geoms", []) if g.geom_type == "Polygon"]
    if not parts:
        return None                       # nothing polygonal survived — quarantine
    return parts[0] if len(parts) == 1 else MultiPolygon(parts)

repaired = snapped.copy()
mask = ~repaired.geometry.is_valid
repaired.loc[mask, "geometry"] = repaired.loc[mask, "geometry"].map(polygonal_only)

Returning None rather than an empty polygon is deliberate: a parcel with no polygonal interior is a missing input, not a repaired one, and it should be quarantined rather than accepted.

Step 4: Measure the area each repair moved

The delta is what decides whether a repair is safe, and it is only available if the original is still to hand.

before = snapped.geometry.area
after = repaired.geometry.area
repaired["repair_area_delta"] = (after - before).abs() / before.replace(0, float("nan"))

BUDGET = 0.0005                                    # 0.05% of parcel area
repaired["repair_status"] = (repaired["repair_area_delta"]
                             .gt(BUDGET)
                             .map({True: "review", False: "accepted"})
                             .where(mask, "unchanged"))
Area moved by repair, against the budgetFractional area change for precision snapping, duplicate-vertex removal and bow-tie resolution, compared with a delta budget set below the tightest rule margin.Precision snap at 0.01 ft0.0002% area movedDuplicate vertex removal0.001% area movedDelta budget0.05% area movedBow-tie resolution14% area movedThe bow-tie figure is not an error introduced by repair — the original area was the difference between two cancelling lobes.
Under the budget a repair cannot move a verdict. Over it, the parcel goes to review rather than into a report.

Step 5: Record what changed, per feature

A repair that is not recorded is indistinguishable from data the supplier published — precisely the confusion an audit trail exists to prevent.

def repair_records(orig, fixed, reasons):
    for idx in reasons.index:
        yield {
            "parcel_id": orig.at[idx, "parcel_id"],
            "reason": reasons.at[idx],
            "operation": "set_precision+make_valid",
            "grid": GRID,
            "vertices_before": len(orig.at[idx, "geometry"].exterior.coords),
            "area_before": float(orig.at[idx, "geometry"].area),
            "area_after": float(fixed.at[idx, "geometry"].area),
            "area_delta_frac": float(fixed.at[idx, "repair_area_delta"]),
            "status": fixed.at[idx, "repair_status"],
        }
The repair record, field by fieldParcel identifier, GEOS reason, operation and grid, vertex counts, areas before and after, the fractional delta and the resulting status.parcel_id + reasonwhat, and why it failedoperation + gridset_precision + make_valid, at 0.01 ftvertices before / aftershows how much was collapsedarea before / after / deltathe number the budget is applied tostatusaccepted, review, or quarantined
Answers the question a reviewer eventually asks — did you change the parcel, and by how much — from the record alone.

Verification

Confirm three things before releasing the repaired layer. Every geometry is now valid; no parcels were lost; and the accepted repairs all sit inside the budget.

assert repaired.geometry.is_valid.all(), "repair left invalid geometry behind"
assert len(repaired) == len(parcels), "row count changed — repairs must not drop parcels"
accepted = repaired[repaired["repair_status"] == "accepted"]
assert accepted["repair_area_delta"].max() <= BUDGET

print(repaired["repair_status"].value_counts())
print(f"total area moved: {(after - before).abs().sum():.1f} sq ft")

The total area moved is worth watching across refreshes. A stable figure is routine; a jump means the incoming data changed character, which is a signal worth chasing before it reaches a report.

Common Pitfalls

  • Using buffer(0) instead. It is the old trick and it silently discards parts of some self-intersecting shapes. make_valid is explicit about what it produces; buffer(0) is not.
  • Accepting a GeometryCollection into the layer. Later area and overlay operations behave unpredictably on mixed collections. Filter to polygons at the point of repair.
  • Repairing in a geographic frame. The precision grid then means degrees, and a hundredth of a degree is about a kilometre. Project first.
  • Discarding the original. Without it there is no delta, no record, and no answer to what changed.

Frequently Asked Questions

Why does repairing sometimes halve a parcel’s area?

Because a self-intersection with opposite ring orientation was cancelling. The two lobes carried opposite signs and the original area was the difference between them. The repaired figure is the correct one, and the parcel is still worth reviewing since its boundary was genuinely ambiguous.

Should the grid be the same for every layer?

Not necessarily. It should be well below each layer’s own survey accuracy, and a constraint layer digitised at a coarse scale can take a coarser grid than a surveyed parcel fabric. What must be consistent is the grid within a layer, and across partitions of the same layer.

What if make_valid produces more polygons than expected?

That is the honest answer to a badly self-intersecting ring, and it usually means the parcel’s boundary was genuinely ambiguous. Keep all polygonal parts, flag the parcel, and let a person decide — this is not a case for a heuristic.

Can this run in parallel across a large fabric?

Yes: repair is a pure function of one geometry with no shared state. The only requirement is that every partition uses the same precision grid, or boundaries between partitions will no longer match.

How does this interact with reprojection?

Repair after reprojection, not before. A geometry that was valid in its source frame can develop a self-intersection once coordinates are transformed and rounded, so validating only on the way in misses exactly the defects the transformation introduced. The order that works is: assign the frame, reproject, snap to the precision grid, then repair — with the validity check immediately after the reprojection that produced the working copy.

Should repaired geometry be written back to the source?

Never to the supplier’s file, and always to the working store. The archived snapshot is the evidence of what was published; the working copy is what the pipeline measured. Conflating the two removes the ability to answer what changed, which is the whole reason the delta is being recorded in the first place.

What if the invalid fraction is very high?

Stop and look upstream before repairing anything. A fabric where several per cent of parcels are invalid, especially with one dominant reason, is describing a systematic problem in how the data was produced — an export setting, a conversion step, a tool that ignores ring winding. Repairing it locally works and commits you to repairing it again on every refresh, whereas one conversation with the publisher can remove the defect at source permanently. Repair is the right response to residual noise; it is the wrong response to a broken pipeline somewhere else.

Can the repaired layer simply replace the original?

No, and the distinction is worth being firm about. The archived snapshot is the evidence of what the agency published, and the repaired copy is what the pipeline measured. Keeping both is what allows the question “did you change this parcel, and by how much” to be answered from the record rather than from memory — and it costs only the storage of a second copy of a layer that is measured in tens of megabytes.

Finally, treat the invalid fraction as a metric rather than as a nuisance. Tracked across refreshes it is one of the cheapest available indicators of upstream data health, and it moves before anything else does — a jump in invalid geometry almost always precedes a jump in anomalous verdicts, which gives a team a week’s warning if anybody is watching it.

Part of: Geometry validation and topology repair