Debugging Shapefile Geometry Errors in QGIS and Python
TL;DR: Run QGIS’s GEOS-based Check Validity tool to classify errors by type, then apply shapely.make_valid() as the primary repair and .buffer(0) as a fallback in Python. Finish with shapely.set_precision(grid_size=0.001) to eliminate floating-point micro-duplicates, strip empty geometries, and re-assert is_valid on the whole layer before any equipment export.
Why This Comes Up in Ag-GIS Workflows
Corrupted geometries — self-intersections, invalid rings, duplicate vertices, or null features — are a routine output of the tools agtech teams use every day. Drone orthomosaics traced by auto-segmentation algorithms, RTK boundary surveys logged at 10 Hz, yield monitor passes converted from CSV to polygon, and prescription zones exported from web-based farm management platforms all introduce floating-point artifacts or topology violations that downstream systems refuse to process.
The consequences are concrete: a single self-intersecting polygon in a variable-rate prescription shapefile can cause the equipment’s ISOBUS task controller to reject the entire file at field startup, leaving the operator with no prescription and defaulting to a flat application rate. A layer with mixed Polygon/MultiPolygon types or null geometries breaks shapefile validation for farm equipment checks silently, allowing the corrupted file to reach the export stage where diagnosis is harder.
Without a systematic debug workflow, engineers typically waste time re-digitising boundaries or wrestling with opaque terminal error codes. The method below eliminates both.
Prerequisites
This page assumes the environment described in Shapefile Validation for Farm Equipment is already in place. The only additions needed here are:
shapely>=2.0— forshapely.make_valid()andshapely.set_precision()(both moved to the top-levelshapelynamespace in 2.x; the oldshapely.validation.make_validimport still works but is deprecated)geopandas>=0.14— forGeoDataFrame.is_valid,.is_empty, and.notna()on geometry columns- QGIS 3.28 LTS or later — for the GEOS validation method in the Check Validity processing tool
No additional pip installs are required if geopandas is already installed with a GEOS-linked shapely.
Step 1 — Classify errors visually in QGIS
Before writing a single line of repair code, classify the error types. QGIS’s Check Validity tool runs GEOS directly and produces human-readable error labels per feature. This step saves time by distinguishing trivial floating-point artifacts from structural topology failures that require manual inspection.
- Open the Processing Toolbox (
Ctrl+Alt+Ton Linux/Windows,Cmd+Option+Ton macOS) and search for Check Validity. - Set your input layer to the shapefile under investigation. Under Method, choose
GEOS— not the default QGIS method. GEOS matches the shapely backend used by Python and catches micro-self-intersections that the QGIS legacy method silently ignores. - Run the tool. It produces three output layers:
valid_output,invalid_output, anderror_output. Theerror_outputlayer contains one point per error, labelled with the GEOS error string (e.g.,Self-intersection,Ring self-intersection,Duplicate point,Too few points in geometry component). - Select the
invalid_outputlayer and open the attribute table. Sort by the_errorscolumn. Group identical error types together — this shows whether you have one pathological feature or a systematic problem affecting the entire dataset. - Use Identify Features to click individual error points in
error_outputand visually inspect the vertex geometry. Self-intersections at field boundary corners are usually digitising artifacts; repeated duplicates across many features indicate a CRS precision issue thatset_precisionwill fix in bulk.
The Fix Geometries processing algorithm provides a quick GUI-based repair for small datasets, but it does not give you control over repair strategy, does not report which features changed, and cannot be integrated into an automated pipeline. Use it for one-off manual fixes only.
Step 2 — Repair programmatically in Python
The following self-contained script handles the complete repair sequence: load with explicit CRS check, apply make_valid, fall back to buffer(0) for survivors, normalise precision, strip unrepairable empties, and write the clean output. It is directly usable without scaffolding.
import geopandas as gpd
import shapely
from shapely.validation import explain_validity
# ── 1. Load shapefile with explicit CRS guard ──────────────────────────────
gdf = gpd.read_file("field_boundary.shp")
if gdf.crs is None:
raise ValueError(
"CRS is missing. Assign the correct EPSG code before validation: "
"gdf = gdf.set_crs('EPSG:32614')"
)
print(f"Loaded {len(gdf)} features | CRS: {gdf.crs.to_epsg()}")
# ── 2. Identify and report invalid geometries ──────────────────────────────
invalid_mask = ~gdf.geometry.is_valid
if invalid_mask.any():
print(f"\nInvalid features: {invalid_mask.sum()} / {len(gdf)}")
# Log a human-readable reason for each invalid feature
for idx in gdf.index[invalid_mask]:
reason = explain_validity(gdf.at[idx, "geometry"])
print(f" Feature {idx}: {reason}")
# ── 3. Primary repair: make_valid ──────────────────────────────────────────
# shapely.make_valid resolves self-intersections and ring orientation issues
# by decomposing the polygon into valid sub-geometries (may produce
# GeometryCollection on severely broken inputs).
gdf.loc[invalid_mask, "geometry"] = (
gdf.loc[invalid_mask, "geometry"].apply(shapely.make_valid)
)
# ── 4. Fallback: buffer(0) for features still invalid after make_valid ──
still_invalid = ~gdf.geometry.is_valid
if still_invalid.any():
print(f" buffer(0) fallback on {still_invalid.sum()} remaining features")
gdf.loc[still_invalid, "geometry"] = (
gdf.loc[still_invalid, "geometry"].buffer(0)
)
# ── 5. Normalise coordinate precision ─────────────────────────────────────
# grid_size=0.001 snaps coordinates to 1mm grid in projected CRS (metres).
# This eliminates floating-point micro-duplicates that pass is_valid but
# cause equipment terminals to reject the file.
gdf["geometry"] = gdf.geometry.apply(
lambda geom: shapely.set_precision(geom, grid_size=0.001)
)
# ── 6. Strip empty and null geometries ────────────────────────────────────
# make_valid can return GEOMETRYCOLLECTION EMPTY for degenerate inputs.
# Equipment controllers have no concept of a null feature row.
n_before = len(gdf)
gdf = gdf[~gdf.geometry.is_empty & gdf.geometry.notna()].copy()
if len(gdf) < n_before:
print(f"Dropped {n_before - len(gdf)} empty/null features after repair")
# ── 7. Final assertion gate ────────────────────────────────────────────────
remaining_invalid = (~gdf.geometry.is_valid).sum()
assert remaining_invalid == 0, (
f"{remaining_invalid} features are still invalid after repair. "
"Inspect these manually in QGIS before proceeding."
)
print(f"\nAll {len(gdf)} features are valid. Writing clean output.")
gdf.to_file("field_boundary_clean.shp", driver="ESRI Shapefile")
Inline verification — add this immediately after the assert to confirm the output file can be round-tripped:
verify = gpd.read_file("field_boundary_clean.shp")
assert verify.geometry.is_valid.all(), "Round-trip read found invalid geometries"
assert not verify.geometry.is_empty.any(), "Round-trip read found empty geometries"
print(f"Verified: {len(verify)} clean features, CRS {verify.crs.to_epsg()}")
Gotchas and Edge Cases
-
make_validcan return aGeometryCollectioninstead of aPolygonwhen the input is severely broken (for example, a figure-eight field boundary).GeometryCollectionis not a valid prescription geometry type. Aftermake_valid, checkgdf.geom_typeand filter out or flatten collections:gdf = gdf[gdf.geom_type.isin(["Polygon", "MultiPolygon"])]. A subsequent.explode()splitsMultiPolygoninto single parts if needed. -
set_precisionchanges vertex coordinates, which means area calculations performed before this step will differ from those after. Always runset_precisionbefore computing agronomic area metrics, not after. For management zone classification algorithms that rely on zone area for rate calculation, re-compute zone areas from the normalised geometry. -
RTK surveys at 10 Hz generate true duplicate vertices, not floating-point artifacts.
set_precisionalone does not deduplicate them. Useshapely.simplify(geom, tolerance=0.01, preserve_topology=True)beforeset_precisionon RTK-derived boundaries to remove duplicate coordinate pairs without altering the visible boundary shape. -
buffer(0)silently changes ring winding order on some geometries. If your downstream workflow uses signed area or winding-order-dependent algorithms (for example, certain ISOXML task controller parsers), verify that the repaired polygon exterior ring is counter-clockwise usingshapely.is_ccw(geom.exterior)and re-orient if necessary withshapely.geometry.polygon.orient(geom, sign=1.0). -
CRS must be in a projected metre-based system before
set_precisionwithgrid_size=0.001. If your shapefile is in geographic WGS84 (EPSG:4326), a grid size of 0.001 degrees corresponds to roughly 111 metres — far too coarse and will snap vertices to a 111 m grid, destroying field boundary precision. Reproject to the appropriate UTM zone using the approach in understanding CRS in precision agriculture before normalising precision.
This guide is part of Shapefile Validation for Farm Equipment — see there for the full validation pipeline covering CRS harmonisation, attribute constraint enforcement, and controlled export to ISOBUS-compatible formats.
Related
- Shapefile Validation for Farm Equipment — complete production pipeline for CRS normalisation, topology repair, attribute bounds enforcement, and controlled equipment export
- Validating Coordinate Systems for Variable Rate Maps — programmatic CRS consistency checks across shapefiles, GeoTIFFs, and prescription grids before field deployment
- Management Zone Classification Algorithms — spatial clustering and zone delineation workflows that depend on topology-valid field boundaries as input
- Interpolating Sparse Yield Monitor Data with Kriging — spatial interpolation pipeline where null or empty geometries in the point layer cause silent Kriging failures