Setting Geometry Repair Thresholds
ST_MakeValid will fix almost any invalid geometry, and that is exactly why it needs a policy. Applied without thresholds it silently converts polygons into collections, absorbs upstream defects that should have been reported, and turns a data-quality problem into a slow accumulation of geometry nobody recognises. This guide sets the boundary: which defects are repaired automatically, which are repaired only within a bounded change, and which are rejected outright and sent back to the producer. It refines the blocking gate specified in Spatial Data Quality and Validation Standards within Geospatial Data Mesh Fundamentals, and its runtime counterpart is Topology Validation with Dagster Asset Checks.
Prerequisites
| Requirement | Value / Assumption | Notes |
|---|---|---|
| Tools | psql ≥ 14 with PostGIS ≥ 3.3, python3 ≥ 3.11 |
ST_MakeValid gained the method parameter in 3.2 |
| CRS convention | Repair runs on the canonical EPSG:4326 artifact |
Area comparisons use geography |
| Storage | Original geometry retained until repair is accepted | Repair is never in place |
| Access roles | platform-engineer (run), gis-data-steward (set thresholds) |
Thresholds are governed per layer |
| Environment | PRODUCT_ID, PARTITION |
Exported before running |
The retention requirement is load-bearing. A repair applied in place destroys the evidence of what the producer actually sent, which makes both the producer conversation and any later audit impossible.
Step-by-Step Implementation
1. Classify the defect before deciding anything
ST_IsValidReason names the specific defect, and the defect — not the fact of invalidity — determines whether repair is safe.
-- classify.sql — group invalid features by their actual defect.
-- Parameters: :product (text), :partition (text)
SELECT
-- The reason string carries a location; strip it so defects group.
regexp_replace(ST_IsValidReason(geom), '\[.*\]$', '') AS defect,
ST_GeometryType(geom) AS geom_type,
count(*) AS features
FROM product_features
WHERE product = :'product'
AND partition_key = :'partition'
AND NOT ST_IsValid(geom)
GROUP BY 1, 2
ORDER BY features DESC;
Verify the defects cluster. Invalid geometry almost always shares one upstream cause, and a long tail of distinct reasons usually means the source itself is corrupt rather than merely imprecise:
psql --csv -f classify.sql -v product="$PRODUCT_ID" -v partition="$PARTITION"
# One or two defect classes covering 95%+ of features is the normal shape.
2. Compute the repair delta without committing it
The question that decides whether a repair is acceptable is how much the geometry moved. A repair that changes area by a fraction of a percent is a precision fix; one that changes it by ten percent is a different polygon.
-- repair_delta.sql — measure what a repair would do, without applying it.
-- Parameters: :product (text), :partition (text)
WITH candidate AS (
SELECT feature_id,
geom AS original,
ST_MakeValid(geom, 'structure') AS repaired
FROM product_features
WHERE product = :'product' AND partition_key = :'partition'
AND NOT ST_IsValid(geom)
)
SELECT
feature_id,
ST_GeometryType(original) AS type_before,
ST_GeometryType(repaired) AS type_after,
ST_Area(original::geography) AS area_before_m2,
ST_Area(repaired::geography) AS area_after_m2,
CASE WHEN ST_Area(original::geography) > 0
THEN abs(ST_Area(repaired::geography) - ST_Area(original::geography))
/ ST_Area(original::geography)
ELSE NULL END AS area_delta_ratio,
-- Hausdorff distance bounds how far any point moved.
ST_HausdorffDistance(original, repaired) AS hausdorff_deg
FROM candidate;
Verify that the type is preserved for the features you intend to repair automatically:
psql --csv -f repair_delta.sql -v product="$PRODUCT_ID" -v partition="$PARTITION" \
| awk -F, 'NR>1 && $2 != $3 {n++} END {print (n+0) " feature(s) would change geometry type"}'
3. Apply the threshold policy
Three outcomes, decided per feature from the measured delta rather than per layer from a preference.
# repair_policy.py — accept, quarantine, or reject each candidate repair.
import csv
import sys
# Per-layer thresholds, governed by the data steward, not the pipeline author.
POLICY = {
"max_area_delta_ratio": 0.001, # 0.1% — a precision fix, not a reshape
"max_hausdorff_deg": 0.0000135, # ~1.5 m at the equator
"allow_type_change": False, # a polygon must stay a polygon
}
def decide(row: dict) -> str:
"""accept: repair is a precision fix and is applied.
quarantine: repair is plausible but material — a human decides.
reject: repair would change what the feature is; the producer is told."""
if row["type_before"] != row["type_after"] and not POLICY["allow_type_change"]:
return "reject"
delta = row.get("area_delta_ratio") or ""
hausdorff = float(row["hausdorff_deg"] or 0)
if delta == "": # a line or point: use distance alone
return "accept" if hausdorff <= POLICY["max_hausdorff_deg"] else "quarantine"
if float(delta) <= POLICY["max_area_delta_ratio"] \
and hausdorff <= POLICY["max_hausdorff_deg"]:
return "accept"
return "quarantine"
if __name__ == "__main__":
counts = {"accept": 0, "quarantine": 0, "reject": 0}
accepted = []
for row in csv.DictReader(open(sys.argv[1], newline="")):
verdict = decide(row)
counts[verdict] += 1
if verdict == "accept":
accepted.append(row["feature_id"])
print(counts)
# Rejections block the publish; quarantines do not, but they are not repaired either.
if counts["reject"]:
print(f"BLOCKED: {counts['reject']} feature(s) would change geometry type",
file=sys.stderr)
raise SystemExit(1)
open("accepted_ids.txt", "w").write("\n".join(accepted))
Verify the split before applying anything. A partition where most repairs quarantine is telling you the threshold is wrong or the source has degraded:
psql --csv -f repair_delta.sql -v product="$PRODUCT_ID" -v partition="$PARTITION" > /tmp/delta.csv
python3 repair_policy.py /tmp/delta.csv
4. Apply accepted repairs, preserving the original
-- apply_repair.sql — write the repaired geometry to a new column, never over the original.
-- Parameters: :product (text), :partition (text)
UPDATE product_features f
SET geom_repaired = ST_MakeValid(f.geom, 'structure'),
repair_applied_at = now(),
repair_method = 'structure'
FROM (SELECT unnest(string_to_array(pg_read_file('accepted_ids.txt'), E'\n')) AS id) a
WHERE f.feature_id = a.id
AND f.product = :'product'
AND f.partition_key = :'partition';
Verify every accepted feature is now valid and the originals are intact:
psql -c "SELECT count(*) FILTER (WHERE NOT ST_IsValid(geom_repaired)) AS still_invalid,
count(*) FILTER (WHERE geom IS NULL) AS originals_lost
FROM product_features
WHERE product = '$PRODUCT_ID' AND partition_key = '$PARTITION'
AND repair_applied_at IS NOT NULL;"
# expected: 0, 0
Configuration Reference
| Parameter | Scope | Default | Effect |
|---|---|---|---|
max_area_delta_ratio |
Policy | 0.001 |
Above this, a repair is a reshape, not a fix |
max_hausdorff_deg |
Policy | 0.0000135 (~1.5 m) |
Bounds how far any vertex may move |
allow_type_change |
Policy | false |
A polygon becoming a collection breaks consumers |
ST_MakeValid method |
SQL | structure |
Preserves type where possible; linework does not |
| Original retention | Storage | Until the repair is accepted downstream | Evidence for the producer conversation |
| Quarantine disposition | Governance | Human review within one cadence | Not repaired, not published as valid |
The structure method matters. PostGIS’s default linework repair preserves every input vertex and will happily return a GeometryCollection from a Polygon; structure prioritises producing a valid geometry of the same class, which is what a contract-bound product needs.
Common Failure Modes & Fixes
Repairs succeed but downstream consumers break.
Root cause: ST_MakeValid returned a GeometryCollection where the contract promises Polygon. Fix: set allow_type_change: false and use the structure method; treat a type change as a rejection that goes back to the producer.
Area delta is enormous for a small visual change. Root cause: a bow-tie self-intersection, where the repair removes one lobe entirely. The visual difference is small and the semantic difference is total. Fix: this is exactly what the ratio threshold is for — quarantine it and look at the feature.
Every repair quarantines after a source upgrade. Root cause: the producer changed export precision, so geometry that used to be marginally invalid is now substantially so. Fix: this is a producer conversation, not a threshold adjustment. Widening the threshold to make the alert stop is how a systematic upstream defect becomes permanent.
Zero-area slivers pass validity and clog the layer.
Root cause: ST_IsValid returns true for a degenerate polygon; it is valid and useless. Fix: this is a separate advisory check on area, not a repair concern. Do not extend the repair policy to delete features — deletion is a data decision, not a validity one.
The repair is not idempotent across re-runs.
Root cause: repairing geom_repaired in place, so each run repairs the previous repair. Fix: always derive the repair from the original column, as the query above does; the original is the only stable input.
FAQ
Why not just repair everything automatically?
Because ST_MakeValid cannot distinguish a rounding artefact from a genuine defect, and it will fix both with equal confidence. A polygon whose ring self-intersects by a micron because of coordinate rounding should be repaired silently — nobody benefits from that reaching a consumer or a producer. A polygon whose ring crosses itself across half its extent is describing something the producer did not intend, and repairing it produces a plausible shape that is not the feature. The threshold is what separates the two, and without it the second case is silently converted into the first.
What is the right area-delta threshold for my layer?
Derive it rather than guessing. Run the delta query over several historical partitions without applying anything and look at the distribution: for most layers the deltas cluster tightly near zero with a small number of outliers orders of magnitude larger, and the gap between the two is the threshold. If there is no gap — if deltas are smoothly distributed across four orders of magnitude — that is itself the finding, and it usually means the source is producing genuinely damaged geometry rather than imprecise geometry.
Should quarantined features block the publish?
No, but they must not be published as valid either. The workable arrangement is that the partition publishes with the quarantined features excluded and the exclusion recorded in the quality block, so a consumer sees a slightly smaller feature count and can find out why. Blocking the whole partition on a handful of ambiguous features punishes every consumer for a defect affecting few, and publishing them unrepaired ships known-invalid geometry — the exclusion is the only option that is honest to both.
Does the repair need to run before or after reprojection?
After. Reprojection can itself introduce validity problems — a polygon valid in a projected CRS can self-intersect after transformation to geographic coordinates near a pole or the antimeridian — so a repair applied before reprojection can leave the published artifact invalid. Running the gate on the canonical EPSG:4326 artifact, which is what consumers actually receive, is the only placement that guarantees what ships is valid.
Related
- Spatial Data Quality and Validation Standards — the parent topic and the blocking gate this refines
- Topology Validation with Dagster Asset Checks — the same gate expressed as an orchestrated asset check
- Detecting Attribute Drift in Published Layers — the non-geometric drift signal