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.
Prerequisites
Only the geometry stack differs from the parent troubleshooting guide:
shapely==2.0.4
geopandas==0.14.4
numpy==1.26.4
Install with:
pip install shapely==2.0.4 geopandas==0.14.4 numpy==1.26.4
Input requirements:
- Prescription zones as a
GeoDataFrameofPolygon/MultiPolygonfeatures 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
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.
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.
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.
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:
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:
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 ofmake_valid, always check foris_emptyafterwards or you will drop a real zone without warning.
-
Snapping too aggressively moves rate boundaries. A
grid_sizeabove roughly 0.5 m can pull a zone edge across the line that separates two product rates, changing which ground gets which rate. KeepSNAP_TOL_Mbelow the controller’s positioning resolution; 0.05 m is safe for RTK-derived boundaries. -
make_validoutput must be filtered before writing. A repaired bow-tie often returns aGeometryCollectioncontaining aLineString. Writing that to a shapefile either errors or drops the polygon. Thepolygonal_partshelper is not optional — never pass a rawmake_validresult toto_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_validandset_precision, then applyMIN_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.
Related
- Debugging Prescription Export Errors — the full geometry-to-ISOXML export validation gate this repair feeds into
- Resolving ISOXML Schema Validation Failures — the schema-side sibling failure once the geometry is valid
- Debugging Shapefile Geometry Errors in QGIS and Python — QGIS-side diagnosis of the same invalid-boundary polygons