Problem Statement
A weekly shapefile arrives from a partner agency and a fraction of a percent of its polygons are invalid. Those few hundred features are the reason the validity audit keeps finding new work every Monday. This page moves the fix to where it belongs — the import boundary — and produces something the data owner can act on rather than a silent repair nobody sees.
Why the Naive Approach Fails
The usual import loop calls ST_GeomFromText or hands shapely geometry to GeoAlchemy2 and writes whatever arrives. Every invalid feature is written faithfully, and the pipeline reports success.
The second-most-common approach is barely better: wrapping every insert in ST_MakeValid. That silently rewrites the partner’s data on the way in, so the partner never learns their export is broken, and the repaired shapes drift a little further from the source every week.
Production-Ready Implementation
Classification runs before any write and produces one of three outcomes per feature:
from dataclasses import dataclass
from enum import Enum
from shapely import wkb
from shapely.geometry.base import BaseGeometry
from shapely.validation import explain_validity, make_valid
class Outcome(str, Enum):
ACCEPT = "accept" # valid as supplied
REPAIR = "repair" # invalid but mechanically repairable
REJECT = "reject" # evidence of an upstream bug
@dataclass(frozen=True)
class Classified:
outcome: Outcome
geometry: BaseGeometry | None
reason: str | None
area_drift: float | None
# Reasons that mean "the exporter produced nonsense", not "the shape is messy".
FATAL = ("Hole lies outside shell", "Nested holes", "Too few points")
MAX_DRIFT = 0.01 # 1% of area
def classify(geom: BaseGeometry) -> Classified:
if geom.is_valid:
return Classified(Outcome.ACCEPT, geom, None, 0.0)
reason = explain_validity(geom)
if any(reason.startswith(f) for f in FATAL):
return Classified(Outcome.REJECT, None, reason, None)
fixed = make_valid(geom)
if fixed.is_empty or fixed.geom_type not in ("Polygon", "MultiPolygon"):
return Classified(Outcome.REJECT, None,
f"{reason} (repair produced {fixed.geom_type})", None)
drift = (abs(fixed.area - geom.area) / geom.area) if geom.area else 0.0
if drift > MAX_DRIFT:
return Classified(Outcome.REJECT, None,
f"{reason} (area drift {drift:.2%})", drift)
return Classified(Outcome.REPAIR, fixed, reason, drift)The loader stages everything, then promotes in one transaction:
import psycopg
from psycopg import sql
def load(dsn: str, features, source_file: str) -> dict:
counts = {o: 0 for o in Outcome}
with psycopg.connect(dsn) as conn:
with conn.cursor() as cur:
cur.execute("CREATE TEMP TABLE staging (LIKE parcels) ON COMMIT DROP")
with cur.copy(
"COPY staging (source_id, name, geom) FROM STDIN (FORMAT BINARY)"
) as copy:
for feature in features:
result = classify(feature.geometry)
counts[result.outcome] += 1
if result.outcome is Outcome.REJECT:
cur.execute(
"""INSERT INTO import_quarantine
(source_file, source_id, reason, raw_wkb)
VALUES (%s, %s, %s, %s)""",
(source_file, feature.id, result.reason,
wkb.dumps(feature.geometry)),
)
continue
copy.write_row(
(feature.id, feature.name, wkb.dumps(result.geometry))
)
# promote everything staged, in one atomic step
cur.execute("""
INSERT INTO parcels (source_id, name, geom)
SELECT source_id, name, geom FROM staging
ON CONFLICT (source_id) DO UPDATE
SET name = EXCLUDED.name, geom = EXCLUDED.geom
""")
conn.commit()
return {o.value: counts[o] for o in Outcome}Because the staging table is temporary and the promotion is a single statement, a failure anywhere leaves the live table exactly as it was — while the quarantine rows, written on their own connection-level transaction boundary, survive to explain why.
Reprojection belongs in the same pass
Imports rarely arrive in the SRID the table uses, and reprojection interacts with validity in a way that is easy to get backwards. Transform after repair, not before: a self-intersecting polygon reprojected through PROJ stays self-intersecting, but its coordinates have moved, so the repair now operates on numbers that no longer match the source file and the drift comparison loses its meaning.
from pyproj import Transformer
from shapely.ops import transform as shapely_transform
_to_4326 = Transformer.from_crs("EPSG:25833", "EPSG:4326", always_xy=True).transform
def prepare(geom):
"""Repair in the source CRS, then reproject — in that order."""
result = classify(geom)
if result.outcome is Outcome.REJECT:
return result
return Classified(result.outcome,
shapely_transform(_to_4326, result.geometry),
result.reason, result.area_drift)Creating the Transformer once at module scope rather than per feature matters more than it looks: constructing one builds a PROJ pipeline and costs milliseconds, so a per-row construction turns a two-minute import into an hour. The same applies to ST_Transform in SQL, where PostGIS caches the pipeline per session — another reason to reproject in one set-based statement rather than row by row.
Configuration and Tuning Knobs
The FATAL reason list is the policy. Start with the three listed above — a hole outside its shell, nested holes and too-few-points are all coordinate bugs rather than messy geometry — and add to it whenever a repaired feature turns out to have been wrong. Every addition should be traceable to an incident.
MAX_DRIFT decides how much reinterpretation counts as repair rather than replacement. One percent works for parcels. Building footprints deserve a tighter bound; a coarse habitat polygon can tolerate more.
The staging approach costs one extra copy of the incoming batch. For a twelve-thousand-feature weekly file that is nothing. For a hundred-million-row initial load, stage in chunks of a few hundred thousand and promote each chunk, so a failure late in the load does not discard everything.
Verification Steps
-- nothing invalid entered the live table from this run
SELECT count(*) FROM parcels
WHERE NOT ST_IsValid(geom)
AND updated_at > now() - interval '1 hour';
-- the quarantine, grouped for the report to the data owner
SELECT reason, count(*) AS features, min(source_id) AS example
FROM import_quarantine
WHERE source_file = '2026-W32-parcels.shp'
GROUP BY reason
ORDER BY features DESC;The second query is the deliverable. It names the failure, counts it and gives an example id the partner can look up in their own system — which is what turns a recurring cleanup into a fixed bug.
Gotchas Checklist
shapely.make_validandST_MakeValidare the same GEOS call but different defaults. Shapely’smake_validuses the linework method unless you passmethod="structure"on shapely 2.1 or newer; PostGIS defaults the same way. If your SQL repairs and Python repairs disagree, this is why.explain_validityreturns a human string, not a stable code. Match withstartswithon the leading phrase as shown, never with equality — the coordinate suffix in the message differs per geometry.- Quarantine the original bytes, not the repaired shape. The point of the table is to reproduce the problem; a repaired copy cannot do that.
ON COMMIT DROPtemp tables are per-session. If your loader uses a connection pool and hands the connection back between the copy and the promote, the staging table vanishes. Keep both on the same explicit connection, as above.- Do not let the quarantine grow unbounded. Add a retention policy — ninety days is generous — or it becomes a second copy of every bad file you have ever received.
Related Topics
- Geometry Validity and Repair — parent topic: audit, repair and prevention
- Enforcing Validity With Check Constraints — the database-side backstop for this pipeline
- ST_MakeValid Strategies for Polygon Repair — choosing the repair method deliberately
- Session Management for Spatial Data — transaction boundaries for loaders like this one