Fixing Shapely Invalid Geometry Errors

TL;DR: Locate invalid zones with explain_validity, repair them with make_valid (falling back to buffer(0) only for a self-intersecting outer ring), snap duplicate points and drop slivers, then assert is_valid.all() before writing the repaired zones back out with geopandas.

Why Invalid Geometry Stops a Prescription Export

Variable-rate zone polygons are rarely drawn by hand. They fall out of a K-Means or Gaussian-mixture clustering step, a raster-to-vector conversion of a management-zone raster, or a dissolve/overlay that merges adjacent same-rate cells. Every one of those operations can emit a geometry that violates the OGC simple-features rules: a self-intersecting “bow-tie” ring, a ring that touches itself at a single vertex (self-tangency), or consecutive duplicate coordinates that create a zero-length segment. shapely’s is_valid returns False for all of these.

The consequence is not cosmetic. A controller’s point-in-polygon rate lookup is undefined on a self-intersecting boundary, so John Deere GreenStar, Trimble, and Raven import paths reject the feature — often failing the entire file with a generic “invalid boundary” message that names no zone. Worse, the geometry frequently renders fine in QGIS, so the problem is invisible until the file reaches the cab. This guide is the concrete repair procedure behind the geometry gate in debugging prescription export errors; run it whenever the controller complains about a boundary or whenever is_valid flags a zone.

What make_valid does to a self-intersecting boundary On the left, a bowtie-shaped ring whose edges cross at a single point, marked in red; its reported area is wrong because the two lobes have opposite winding. In the middle, an arrow labelled make_valid. On the right, the same ring returned as two separate polygons with correct areas, and a note that the total area changes, so the repair must be recorded rather than applied silently. Invalid: ring crosses itself self-intersection lobes wind in opposite directions, so the reported area partly cancels make_valid Valid: two polygons 4.1 ha 4.1 ha total area changes on repair — record that it happened, never repair silently Result type MultiPolygon Column type must accept it, or the insert fails promote on load

Prerequisites

Only the geometry stack differs from the parent troubleshooting guide:

TEXT
shapely==2.0.4
geopandas==0.14.4
numpy==1.26.4

Install with:

BASH
pip install shapely==2.0.4 geopandas==0.14.4 numpy==1.26.4

Input requirements:

  • Prescription zones as a GeoDataFrame of Polygon/MultiPolygon features with a numeric rate column.
  • A projected metric CRS (for example EPSG:32615, UTM zone 15N) so that area and snap tolerances are in metres. Repairing geometry in a geographic CRS makes the sliver-area threshold meaningless.

Step-by-Step

Repairing geometry without losing the fact that you did Five steps: detect invalid geometries across the layer, get a per-feature explanation of the defect, repair with make_valid, check the resulting geometry type because a repair can return a collection, and record that a repair took place. Detect is_valid across the layer Explain explain_validity per feature Repair make_valid Check type may become a collection Record that a repair happened A boundary that needed repair is a signal about its source; repairing silently discards the only evidence that the upstream export is producing bad geometry.

Step 1 — Locate invalid geometries with explain_validity

shapely 2.x vectorises is_valid over a GeoSeries, and explain_validity returns a human-readable reason and the offending coordinate. Never repair blindly — read the reason first, because it tells you whether a cheap buffer(0) will suffice or whether you need make_valid.

PYTHON
import geopandas as gpd
from shapely.validation import explain_validity

rx = gpd.read_file("prescription_zones.gpkg")
assert rx.crs is not None and rx.crs.is_projected, "Need a projected CRS in metres"

mask_invalid = ~rx.geometry.is_valid
print(f"{mask_invalid.sum()} of {len(rx)} zones invalid")
for idx, geom in rx.loc[mask_invalid, "geometry"].items():
    print(f"  zone {idx}: {explain_validity(geom)}")

Typical output distinguishes the failure classes: Self-intersection[500123.4 4213880.7] (a bow-tie), Ring Self-intersection[...] (self-tangency), or a duplicate-point artefact reported as a zero-area sub-ring.

Step 2 — Choose make_valid or buffer(0)

make_valid (shapely 2.x, backed by GEOS MakeValid) rebuilds the geometry into a valid one while preserving every valid vertex and the overall topology. buffer(0) exploits the fact that buffering by zero re-noded the boundary, but it can quietly shave a sliver at a self-intersection and will dissolve a self-tangent ring in ways that move the zone line. Default to make_valid; reach for buffer(0) only when the reason is a simple self-intersection on the outer ring and a sub-metre boundary shift is acceptable.

PYTHON
from shapely import make_valid
from shapely.geometry import MultiPolygon

def polygonal_parts(geom):
    """Keep only Polygon/MultiPolygon parts; make_valid can emit collections."""
    if geom.geom_type == "GeometryCollection":
        polys = []
        for g in geom.geoms:
            if g.geom_type == "Polygon":
                polys.append(g)
            elif g.geom_type == "MultiPolygon":
                polys.extend(g.geoms)
        if not polys:
            return None
        return polys[0] if len(polys) == 1 else MultiPolygon(polys)
    return geom

def repair(geom):
    if geom is None or geom.is_empty:
        return None
    if geom.is_valid:
        return geom
    fixed = make_valid(geom)
    return polygonal_parts(fixed)

Step 3 — Snap duplicate points and remove slivers

Duplicate consecutive vertices and near-coincident ring endpoints are best killed by snapping coordinates to a fixed grid with shapely.set_precision, which also closes epsilon-open rings. Follow with an area filter to drop the slivers that repair and snapping can leave behind. Both thresholds are in the projected CRS’s metres.

PYTHON
from shapely import set_precision

SNAP_TOL_M = 0.05        # collapse vertices closer than 5 cm
MIN_ZONE_AREA_M2 = 200.0 # ~0.02 ha; below this is an untreatable sliver

def snap_and_clean(geom):
    if geom is None or geom.is_empty:
        return None
    # grid_size snapping removes duplicate points and closes near-open rings
    snapped = set_precision(geom, grid_size=SNAP_TOL_M)
    snapped = polygonal_parts(snapped) if snapped.geom_type == "GeometryCollection" else snapped
    if snapped is None or snapped.is_empty or snapped.area < MIN_ZONE_AREA_M2:
        return None
    return snapped

Step 4 — Re-check is_valid and write repaired zones

Chain the repair and clean steps over the GeoDataFrame, drop features that collapsed to nothing, then assert that every surviving geometry is valid before writing. The final to_file preserves the rate attribute so the export downstream still carries a treatable rate per zone.

The complete, directly runnable script:

PYTHON
import geopandas as gpd
from shapely import make_valid, set_precision
from shapely.geometry import MultiPolygon
from shapely.validation import explain_validity

SNAP_TOL_M = 0.05
MIN_ZONE_AREA_M2 = 200.0

def polygonal_parts(geom):
    if geom.geom_type == "GeometryCollection":
        polys = []
        for g in geom.geoms:
            if g.geom_type == "Polygon":
                polys.append(g)
            elif g.geom_type == "MultiPolygon":
                polys.extend(g.geoms)
        if not polys:
            return None
        return polys[0] if len(polys) == 1 else MultiPolygon(polys)
    return geom

def repair_and_clean(geom):
    if geom is None or geom.is_empty:
        return None
    fixed = geom if geom.is_valid else make_valid(geom)
    if fixed.geom_type == "GeometryCollection":
        fixed = polygonal_parts(fixed)
        if fixed is None:
            return None
    snapped = set_precision(fixed, grid_size=SNAP_TOL_M)
    if snapped.geom_type == "GeometryCollection":
        snapped = polygonal_parts(snapped)
    if snapped is None or snapped.is_empty or snapped.area < MIN_ZONE_AREA_M2:
        return None
    return snapped

# ── Load ────────────────────────────────────────────────────────────────────
rx = gpd.read_file("prescription_zones.gpkg")
assert rx.crs is not None and rx.crs.is_projected, "Projected metric CRS required"

before_invalid = int((~rx.geometry.is_valid).sum())
print(f"Invalid before repair: {before_invalid}")

# ── Repair ──────────────────────────────────────────────────────────────────
rx["geometry"] = rx.geometry.apply(repair_and_clean)
rx = rx[rx.geometry.notna()].copy()

# ── Verify ──────────────────────────────────────────────────────────────────
assert rx.geometry.is_valid.all(), (
    "Invalid geometry survived: "
    + "; ".join(explain_validity(g) for g in rx.geometry[~rx.geometry.is_valid])
)
assert not rx.geometry.is_empty.any(), "Empty geometry survived cleanup"
assert (rx.geometry.area >= MIN_ZONE_AREA_M2).all(), "Sliver survived area filter"
print(f"All {len(rx)} zones valid, non-empty, and above {MIN_ZONE_AREA_M2:.0f} m²")

# ── Write ───────────────────────────────────────────────────────────────────
rx.to_file("prescription_zones_valid.gpkg", driver="GPKG")
print("Wrote prescription_zones_valid.gpkg")

Inline verification: reopen the repaired file and confirm zero invalid features remain:

PYTHON
check = gpd.read_file("prescription_zones_valid.gpkg")
n_invalid = int((~check.geometry.is_valid).sum())
print(f"Invalid after repair: {n_invalid}")
assert n_invalid == 0, "Repaired file still contains invalid geometry"

Gotchas & Edge Cases

  • buffer(0) silently deletes small zones. On a geometry whose only valid area is below the buffer’s numerical resolution, buffer(0) returns an empty polygon. If you use it instead of make_valid, always check for is_empty afterwards or you will drop a real zone without warning.
Four things a repair can hand back, only one of them boring A table of four possible results from a geometry repair: a single polygon, a multi-polygon where a bowtie split into two parts and the area changed, a geometry collection containing non-polygonal fragments, and an empty geometry. Each row gives the follow-up action. What make_valid returns When What to do next A single Polygon minor self-touch The common case Continue, but record it A MultiPolygon bowtie split in two Area changes Check the area against the record A GeometryCollection lines or points included Degenerate input Keep only the polygonal parts An empty geometry nothing salvageable Zero-area input Reject the feature, do not export
  • Snapping too aggressively moves rate boundaries. A grid_size above roughly 0.5 m can pull a zone edge across the line that separates two product rates, changing which ground gets which rate. Keep SNAP_TOL_M below the controller’s positioning resolution; 0.05 m is safe for RTK-derived boundaries.

  • make_valid output must be filtered before writing. A repaired bow-tie often returns a GeometryCollection containing a LineString. Writing that to a shapefile either errors or drops the polygon. The polygonal_parts helper is not optional — never pass a raw make_valid result to to_file.

  • Area filter belongs after repair, not before. Slivers are frequently created by the repair and snapping steps themselves, so filtering first misses them. Run make_valid and set_precision, then apply MIN_ZONE_AREA_M2.

Frequently Asked Questions

When should I use make_valid instead of buffer zero?

Use make_valid as the default because it preserves topology and never moves a valid vertex, which keeps prescription zone boundaries exact. Reserve buffer(0) for the narrow case of a single self-intersecting outer ring where you accept that shapely may shave a thin sliver off the crossing point. For agronomic zone lines that mark a real product change, make_valid is the safer choice.

Why does make_valid return a GeometryCollection?

Repairing a bow-tie polygon can split it into a polygon plus a leftover line or point, which shapely returns as a GeometryCollection. Writing that directly to a shapefile fails or drops parts silently. Keep only the polygonal parts after make_valid and rebuild a Polygon or MultiPolygon before export.

Does shapely automatically close open rings?

Yes, constructing a Polygon in shapely 2.x closes the exterior ring by repeating the first coordinate, so most in-memory geometries are closed. The failure appears when rings are assembled by hand or round-tripped through a lossy format, arriving with a first and last vertex that differ by a floating-point epsilon. Snapping coordinates to a fixed precision before validation removes that class of error.

Parent Guide

This guide is part of Debugging Prescription Export Errors — see there for the full export validation gate covering CRS, units, attribute schema, and ISOXML alongside geometry.