Repairing Invalid Geometries Before Load
ST_MakeValid will return something for almost any input, and that is exactly why it should not be applied blindly. Classify the defect first — self-intersection, unclosed ring, duplicate vertex, degenerate sliver — apply the narrowest repair that addresses it, then verify that the repaired geometry still means what the original did by comparing area, type and vertex count. Anything the repair changes beyond a tolerance is not a fix, it is a different feature, and it belongs in quarantine rather than in the table.
When to Use This Pattern
- A load into a typed, validity-checked PostGIS column is failing on a small fraction of features.
- The source is known to produce self-intersections — hand-digitised data, or an export from a tool with looser rules than the simple-features specification.
- Dropping the bad features is not acceptable, because they are real parcels, buildings or boundaries that someone will look for.
- A previous blanket
ST_MakeValidproduced surprises — geometry collections in a polygon column, or areas that changed by more than rounding.
Complete Working Example
The repairer classifies, fixes narrowly, and verifies. Every branch records what it did.
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Optional
import shapely
from shapely.geometry.base import BaseGeometry
class Defect(str, Enum):
NONE = "none"
SELF_INTERSECTION = "self_intersection"
RING_SELF_INTERSECTION = "ring_self_intersection"
DUPLICATE_VERTICES = "duplicate_vertices"
TOO_FEW_POINTS = "too_few_points"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class RepairResult:
defect: Defect
repaired: Optional[BaseGeometry]
area_change: float # relative, 0.0 means unchanged
type_changed: bool
accepted: bool
note: str
# A repair that changes the area by more than this is not a repair.
AREA_TOLERANCE = 1e-6
def classify(geom: BaseGeometry) -> Defect:
if shapely.is_valid(geom):
return Defect.NONE
reason = shapely.is_valid_reason(geom) or ""
lowered = reason.lower()
if "ring self-intersection" in lowered:
return Defect.RING_SELF_INTERSECTION
if "self-intersection" in lowered:
return Defect.SELF_INTERSECTION
if "too few points" in lowered:
return Defect.TOO_FEW_POINTS
if "repeated point" in lowered or "duplicate" in lowered:
return Defect.DUPLICATE_VERTICES
return Defect.UNKNOWN
def repair(geom: BaseGeometry) -> RepairResult:
defect = classify(geom)
if defect is Defect.NONE:
return RepairResult(defect, geom, 0.0, False, True, "already valid")
if defect is Defect.TOO_FEW_POINTS:
# Not repairable: a two-point "polygon" has no area to preserve. Quarantine.
return RepairResult(defect, None, 0.0, False, False,
"degenerate: fewer points than the type requires")
original_area = geom.area
original_type = geom.geom_type
if defect is Defect.DUPLICATE_VERTICES:
# The narrowest possible fix: a zero-distance simplify drops repeated
# points without touching the shape.
fixed = shapely.simplify(geom, 0.0)
else:
# make_valid is the general tool. It is correct and it is blunt: a bow-tie
# becomes a MultiPolygon, and a sliver may vanish entirely.
fixed = shapely.make_valid(geom)
if fixed.is_empty:
return RepairResult(defect, None, 1.0, False, False,
"repair produced an empty geometry — the feature had no area")
area_change = (
abs(fixed.area - original_area) / original_area if original_area > 0 else 0.0
)
type_changed = fixed.geom_type != original_type
if area_change > AREA_TOLERANCE:
return RepairResult(defect, fixed, area_change, type_changed, False,
f"repair changed the area by {area_change:.2%} — quarantined")
return RepairResult(defect, fixed, area_change, type_changed, True,
f"repaired ({defect.value}); type "
+ ("changed to " + fixed.geom_type if type_changed else "unchanged"))
Normalising the type afterwards is what keeps the load from failing on a surprise:
def to_multipolygon(geom: BaseGeometry) -> Optional[BaseGeometry]:
"""Coerce a repair result into the column's declared type, or refuse."""
if geom.geom_type == "MultiPolygon":
return geom
if geom.geom_type == "Polygon":
return shapely.MultiPolygon([geom])
if geom.geom_type == "GeometryCollection":
# make_valid on a bow-tie can return a collection containing lines and
# points alongside the polygons. Keep only the polygonal parts.
polygons = [g for g in geom.geoms if g.geom_type in ("Polygon", "MultiPolygon")]
if not polygons:
return None
return shapely.union_all(polygons)
return None # a LineString where a polygon was expected: quarantine it
The classification step is worth defending, because “just call make_valid and check the area” would be shorter. Two things are lost by skipping it. First, the narrow repairs are genuinely better: a zero-distance simplify on duplicate vertices preserves the exact coordinates, where make_valid may reconstruct the ring and move nothing visibly but change the WKB, which then breaks any downstream comparison keyed on geometry bytes. Second, the defect class is the most useful thing to aggregate: a delivery whose failures are all RING_SELF_INTERSECTION came out of one tool with one bug, and telling the publisher that is far more actionable than telling them nine features were invalid.
unknown row is the one worth opening by hand — it is either a new defect class worth classifying or a genuine oddity worth understanding.Parameter & Option Reference
| Parameter | Type | Default | Spatial notes |
|---|---|---|---|
AREA_TOLERANCE |
float |
1e-6 |
Relative. Floating-point reconstruction moves the area in the twelfth digit; anything larger changed the feature. |
simplify(0.0) |
— | — | Removes repeated points without moving any vertex. The narrowest repair available, and the right one for duplicates. |
make_valid |
— | — | Correct and blunt. Use it for genuine topology defects and always check the type and area afterwards. |
| type coercion | MultiPolygon |
— | Promote polygons, filter collections to their polygonal parts, refuse anything else. |
| empty result | quarantine | — | An empty repair means the feature had no area to preserve. That is a data question, not a fix. |
is_valid_reason |
— | — | The classification input. Its text is stable enough to match on, and it names the offending coordinate. |
Verification & Testing
Each defect class gets a fixture and an assertion about what the repair preserved.
import shapely
def test_bowtie_becomes_multipolygon_with_the_same_area() -> None:
bowtie = shapely.from_wkt("POLYGON((0 0, 2 2, 2 0, 0 2, 0 0))")
result = repair(bowtie)
assert result.defect is Defect.RING_SELF_INTERSECTION
assert result.accepted
assert result.repaired.geom_type == "MultiPolygon"
assert result.type_changed
assert result.area_change < AREA_TOLERANCE
def test_duplicate_vertices_take_the_narrow_repair() -> None:
dup = shapely.from_wkt("POLYGON((0 0, 1 0, 1 0, 1 1, 0 1, 0 0))")
result = repair(dup)
assert result.repaired.area == dup.area
assert not result.type_changed
def test_degenerate_is_quarantined_not_repaired() -> None:
sliver = shapely.from_wkt("POLYGON((0 0, 1 1, 0 0))")
result = repair(sliver)
assert not result.accepted
assert result.repaired is None
def test_a_repair_that_changes_the_area_is_refused(pathological_geometry) -> None:
result = repair(pathological_geometry)
if result.area_change > AREA_TOLERANCE:
assert not result.accepted, "an area-changing repair must never be accepted"
In PostGIS the equivalent inspection is one query, and it is the fastest way to size the problem before writing any Python at all:
-- How many, of what kind, and where?
SELECT (ST_IsValidDetail(geom)).reason AS reason,
count(*) AS features,
ST_Extent(geom) AS where_they_are
FROM parcels_stage
WHERE NOT ST_IsValid(geom)
GROUP BY 1
ORDER BY 2 DESC;
Common Pitfalls
- Applying
make_validto everything. It works, and it silently turns some polygons into collections and some slivers into nothing. Classifying first costs one call tois_valid_reasonand keeps the surprises visible. - Not checking the geometry type afterwards. A
GEOMETRYCOLLECTIONin ageometry(MultiPolygon)column fails at the load, several steps from the repair, with an error that names the column. Coerce and refuse explicitly. - Ignoring the area. A repair that changes the area by five per cent has changed the feature. The area comparison is two lines and is the only thing separating a repair from a silent edit.
- Repairing after reprojection. Transform first and the repair operates on coordinates a projection has already distorted near the source’s edges. Repair in the source CRS, then transform — the order used in building ETL chains for vector data.
- Discarding the original. Once repaired, the evidence is gone. Store the original WKB with the repair record, so the decision can be revisited when GEOS changes its interpretation or someone disputes the result.
Frequently Asked Questions
Should repairs happen in Python or in PostGIS?
PostGIS is faster for bulk work and ST_MakeValid is the same GEOS implementation, so for a staging table already loaded it is the natural choice. Python wins when the repair needs branching logic per defect class, which is exactly what this recipe does. A common split is to classify in SQL to size the problem, then repair in Python where the policy lives.
What tolerance should the area check use?
Start at 1e-6 relative and look at the distribution. Reconstruction noise sits many orders of magnitude below that, so the tolerance is not delicate — its job is to separate two populations that are usually far apart. If your data shows a continuum rather than a gap, that is itself a finding: the geometries are being changed rather than fixed, and the repair strategy needs revisiting.
Does a repair need to be recorded per feature?
Yes, and cheaply: the defect class, the area change and whether the type changed. Three small columns on the load record answer the question that comes up months later — “was this parcel’s boundary edited by us or by the publisher?” — which is otherwise unanswerable and occasionally contractual.
What about topology across features?
This recipe fixes each geometry in isolation, which is the right scope for a load. Gaps and overlaps between features are a different problem, needing a topological model rather than per-feature repair, and attempting it during a load is how pipelines acquire hour-long steps that nobody can explain. Validate it separately and report it, rather than fixing it inline.
Related
- Spatial validation & sync tasks — the boundary this repair belongs to
- Validating coordinate systems before ETL — the check that runs before this one
- Storing failed geometries in a PostGIS dead-letter queue — where quarantined features go
- Making PostGIS upserts idempotent for feature loads — the load these repairs feed