Repairing invalid geometries with ST_MakeValid
This page adds a repair step to a dbt staging layer that is safe to run in production: it fixes the defects that can be fixed, refuses the ones that cannot, records what changed for every repaired row, and never silently turns a polygon into something else.
When to use this approach
- Invalid geometry arrives regularly and blocking the build is not an option. A feed with a handful of self-intersections every week needs repair-and-record, not a red build every week.
- A downstream function is failing on invalid input.
ST_Intersection,ST_UnionandST_Bufferall misbehave on invalid polygons, sometimes by returning a wrong answer rather than an error. - You already quarantine and now need to recover some of it. Repair complements the quarantine pattern in quarantining invalid geometries in staging rather than replacing it.
Prerequisites
- PostGIS 3.0+ (
ST_MakeValidwith themethodparameter arrived in 3.2 and is worth having). - A quarantine table already in place, so unrepairable rows have somewhere to go.
ST_IsValidReasonavailable for recording why a geometry was invalid before repair.- Agreement that repaired geometry is acceptable for the use case. For a regulated boundary set it may not be.
Step-by-step instructions
1. Classify the defects before repairing anything
Not all invalidity is equal, and the reason string tells you which kind you have.
-- analyses/invalidity_profile.sql
select
split_part(st_isvalidreason(geom), '[', 1) as reason,
count(*) as features,
round(100.0 * count(*) / sum(count(*)) over (), 1) as pct
from {{ source('ops', 'zones_raw') }}
where not st_isvalid(geom)
group by 1
order by features desc
reason | features | pct
-------------------------------+----------+------
Self-intersection | 412 | 61.2
Ring Self-intersection | 158 | 23.5
Duplicate Rings | 61 | 9.1
Too few points in geometry | 28 | 4.2
Nested holes | 14 | 2.1
Verify the profile is stable between deliveries. A sudden new reason category is a change at the source, and repairing it blindly hides that signal — the drift monitoring in alerting on geometry drift between runs is the right place to notice it.
2. Repair, but keep the type contract
ST_MakeValid may return a different geometry type than it was given: a self-intersecting polygon becomes a multipolygon, and a degenerate one can come back as a line or a collection. A staging model that declares “polygons” and silently emits a GEOMETRYCOLLECTION breaks everything downstream.
-- models/staging/stg_zones.sql
{{ config(materialized = 'table') }}
with raw as (
select zone_id, zone_name, geom, st_isvalid(geom) as was_valid,
st_isvalidreason(geom) as invalid_reason
from {{ source('ops', 'zones_raw') }}
),
repaired as (
select
zone_id, zone_name, was_valid, invalid_reason,
case when was_valid then geom
else st_makevalid(geom, 'method=structure keepcollapsed=false')
end as geom_repaired
from raw
),
typed as (
select
zone_id, zone_name, was_valid, invalid_reason,
-- keep only polygonal components; a repair that yields lines or points
-- is a repair that changed the meaning of the row
st_collectionextract(geom_repaired, 3) as geom,
st_geometrytype(geom_repaired) as repaired_type
from repaired
)
select * from typed where geom is not null and not st_isempty(geom)
method=structure is the newer repair algorithm and generally gives cleaner results for polygon data than the default linework method; keepcollapsed=false drops the zero-area fragments that would otherwise arrive as lines. ST_CollectionExtract(…, 3) then keeps only polygonal parts, so the output type contract holds.
Verify the repair produced polygons and only polygons:
select st_geometrytype(geom) as type, count(*)
from {{ ref('stg_zones') }} group by 1;
-- Expect ST_Polygon and ST_MultiPolygon only
3. Record every repair
A repair that leaves no trace is indistinguishable from source data, which makes a later “this boundary looks wrong” conversation unanswerable.
-- models/staging/stg_zone_repairs.sql
{{ config(materialized = 'incremental', unique_key = ['zone_id', 'detected_at']) }}
select
zone_id,
'{{ run_started_at }}'::timestamp as detected_at,
invalid_reason,
repaired_type,
round((st_area(geom) / nullif(st_area(geom_original), 0))::numeric, 6) as area_ratio
from {{ ref('stg_zones_with_original') }}
where not was_valid
The area_ratio is the number that matters in review. A repair that preserves area is usually a topological tidy-up; one that removes 12 per cent of a zone changed the answer, and someone should decide whether that is acceptable rather than discovering it in a dashboard.
Verify the repair log is small and stable:
select detected_at::date, count(*) as repairs, round(avg(area_ratio), 4) as mean_area_ratio
from {{ ref('stg_zone_repairs') }} group by 1 order by 1 desc limit 14;
4. Fail the build when repair changes too much
-- tests/assert_repair_within_tolerance.sql
select zone_id, invalid_reason, area_ratio
from {{ ref('stg_zone_repairs') }}
where detected_at > current_date - interval '1 day'
and (area_ratio < 0.98 or area_ratio > 1.02)
models:
- name: stg_zones
columns:
- name: geom
tests:
- not_null
- dbt_utils.expression_is_true:
expression: "st_isvalid(geom)"
The validity test after repair looks redundant and is not: ST_MakeValid can fail to produce valid output on pathological input, and finding that out in staging is far better than in a join.
Configuration reference
| Parameter | Where | Values | Note |
|---|---|---|---|
method |
ST_MakeValid |
linework (default), structure |
structure gives cleaner polygon results; PostGIS 3.2+ |
keepcollapsed |
ST_MakeValid |
true / false | false drops zero-area fragments that would otherwise become lines |
ST_CollectionExtract type |
model SQL | 1 point, 2 line, 3 polygon | Enforces the output type contract after repair |
| area tolerance | test | ±2% | Tighten for regulated data; loosen only with a reason |
| repair log retention | log model | 90 days | Long enough to answer “when did this boundary change?” |
| quarantine destination | separate model | — | Unrepairable rows are data, not errors to discard |
Gotchas & edge cases
- Repair is not idempotent across versions. A different GEOS build can repair the same input differently, which is one more reason to pin versions, as in managing PostGIS extension versions across environments.
- A repaired multipolygon breaks a
uniqueassumption if downstream code assumed one ring per feature. Test the geometry type, not only validity. ST_MakeValidon a valid geometry is a no-op but not free. Guard it withcase when st_isvalid(...)so the common path costs nothing.- Repair can produce an empty geometry. Filter empties explicitly; an empty polygon passes
ST_IsValidand then silently matches nothing in every join. - Never repair in the join.
ST_Intersects(ST_MakeValid(a.geom), b.geom)disables the index and repairs the same row once per candidate pair.
FAQ
Should I repair or quarantine?
Both, split by outcome. Repair the defects that are clearly encoding artefacts — self-intersections from a digitising tool, duplicate rings — and quarantine anything whose repair materially changes the shape. The area-ratio test is what draws that line automatically instead of case by case.
Is method=structure always better?
For polygon coverage data, usually. The linework method preserves every input line, which can leave slivers and spikes; the structure method rebuilds the polygon’s area and tends to produce what a person would draw. For linear data, where the linework is the content, the default is the safer choice.
Where should repair live — staging or a separate model?
In staging, as close to the source as possible, so nothing downstream ever sees invalid geometry. Keeping the repair log as a separate model is what allows staging to stay a simple one-row-per-feature model while the audit trail accumulates alongside it.
How do I tell whether a repair mattered?
Compare area, vertex count and geometry type before and after. Area is the headline number; a type change from polygon to multipolygon usually indicates a bowtie, which is benign; a large vertex-count drop suggests the repair simplified rather than fixed, which deserves a look.
Related
- Geometry Validation and Data Quality — the validation layer this belongs to.
- Quarantining Invalid Geometries in Staging — where unrepairable rows go.
- Testing Geometry Validity with dbt Generic Tests — the assertions around this step.
Up: Part of Geometry Validation and Data Quality.