Quarantining Invalid Geometries in Staging
This page shows you how to split a dbt staging model into two paths — a clean stream of valid geometry and a quarantine table of everything that failed — so invalid rows are captured with their reason and alerted on instead of silently dropped or crashing a downstream join.
When to use this approach
Quarantining, rather than failing the whole build or blindly repairing, is the right call when:
- You cannot afford to drop rows silently, but must not block the pipeline either. A quarantine split lets valid data flow to marts while invalid rows are set aside for review, so one bad geometry does not halt every downstream model. This is the routing half of the geometry validation and data quality sweep.
- Repair is not always acceptable. For legal, cadastral, or regulatory boundaries, having
ST_MakeValidmove a vertex is worse than rejecting the row. Quarantine preserves the original for a human decision. If you only need a hard pass/fail gate instead, use testing geometry validity with dbt generic tests. - You want a feedback loop to the upstream data owner. A persisted quarantine table with
ST_IsValidReasonis evidence you can hand back to whoever produces the source, rather than a transient test failure that disappears on the next green run.
Prerequisites
- dbt Core 1.7+ with a spatial adapter —
dbt-postgresagainst PostGIS 3.x (forST_IsValidReason), ordbt-duckdbwith the spatial extension. ST_IsValid,ST_MakeValid,ST_IsValidReasonreachable on the target. Setup is in setting up PostGIS with dbt.- A canonical SRID decided, so the split can also route wrong-SRID rows to quarantine. Reprojection mechanics are in automating CRS conversions in dbt pipelines.
CREATEgrants on the target schema so both the clean and quarantine models can materialize as tables.
# dbt_project.yml
vars:
canonical_srid: "{{ env_var('DBT_CANONICAL_SRID', '4326') }}"
# 'repair' merges ST_MakeValid successes back into clean; 'reject' keeps them quarantined
invalid_geometry_policy: "{{ env_var('DBT_INVALID_GEOM_POLICY', 'repair') }}"
Step-by-step instructions
1. Build a single classified base model
The cleanest design classifies every row exactly once in an intermediate model, so the valid and quarantine models are just filtered views of the same source of truth. This avoids the classic bug where the two paths use slightly different predicates and a row lands in neither — or both.
-- models/staging/stg_parcels_classified.sql
{{ config(materialized='ephemeral') }}
with source as (
select parcel_id, geom
from {{ source('gis', 'parcels_raw') }}
),
stamped as (
select
parcel_id,
ST_SetSRID(geom, {{ var('canonical_srid') }}) as geom
from source
),
classified as (
select
parcel_id,
geom,
geom is null as is_null,
ST_SRID(geom) <> {{ var('canonical_srid') }} as wrong_srid,
coalesce(ST_IsValid(geom), false) as valid_on_arrival,
ST_MakeValid(geom) as geom_repaired
from stamped
)
select
parcel_id,
geom,
geom_repaired,
is_null,
wrong_srid,
valid_on_arrival,
coalesce(ST_IsValid(geom_repaired), false) as valid_after_repair
from classified
Verify every source row is classified exactly once:
select count(*) as total,
count(*) filter (where valid_on_arrival) as clean,
count(*) filter (where not valid_on_arrival) as needs_attention
from {{ ref('stg_parcels_classified') }};
-- clean + needs_attention must equal total
2. Derive the clean staging model
The clean model selects rows that are valid — either on arrival, or after repair when policy allows it. Branch the predicate on invalid_geometry_policy so the same code serves a repair-aggressive telemetry feed and a reject-only cadastral feed.
-- models/staging/stg_parcels.sql
{{ config(materialized='table', tags=['spatial', 'staging']) }}
select
parcel_id,
{% if var('invalid_geometry_policy') == 'repair' %}
case when valid_on_arrival then geom else geom_repaired end as geometry
{% else %}
geom as geometry
{% endif %}
from {{ ref('stg_parcels_classified') }}
where not is_null
and not wrong_srid
and {% if var('invalid_geometry_policy') == 'repair' %}
valid_after_repair
{% else %}
valid_on_arrival
{% endif %}
Verify the clean model contains only valid, correctly-projected geometry:
select count(*) as leaks
from {{ ref('stg_parcels') }}
where geometry is null
or not ST_IsValid(geometry)
or ST_SRID(geometry) <> {{ var('canonical_srid') }};
-- expect 0
3. Derive the quarantine model with captured reasons
The quarantine model is the complement: everything the clean model excluded, tagged with a machine-readable failure category and the human-readable ST_IsValidReason. Persist it as a table so it survives across runs for triage and trending.
-- models/staging/stg_parcels_quarantine.sql
{{ config(materialized='table', tags=['spatial', 'dq', 'quarantine']) }}
select
parcel_id,
geom as original_geometry,
case
when is_null then 'null_geometry'
when wrong_srid then 'wrong_srid'
when not valid_after_repair then 'irreparable'
else 'repaired_but_rejected_by_policy'
end as failure_category,
{% if target.type == 'postgres' %}
ST_IsValidReason(geom) as invalid_reason,
{% else %}
'see failure_category' as invalid_reason,
{% endif %}
current_timestamp as quarantined_at
from {{ ref('stg_parcels_classified') }}
where is_null
or wrong_srid
or {% if var('invalid_geometry_policy') == 'repair' %}
not valid_after_repair
{% else %}
not valid_on_arrival
{% endif %}
Verify the two paths partition the source with no overlap and no gap:
select
(select count(*) from {{ ref('stg_parcels') }}) as clean_rows,
(select count(*) from {{ ref('stg_parcels_quarantine') }}) as quarantined_rows,
(select count(*) from {{ source('gis', 'parcels_raw') }}) as source_rows;
-- clean_rows + quarantined_rows should equal source_rows
4. Alert on quarantine growth
A quarantine that nobody watches is just a slower silent drop. Attach a dbt test that fails — or warns — when the quarantine exceeds an acceptable share of input, so a schema change upstream trips an alert in CI rather than surfacing weeks later.
# models/staging/_staging.yml
version: 2
models:
- name: stg_parcels_quarantine
description: "Invalid, null, or wrong-SRID parcel geometries held for review."
tests:
- dbt_utils.expression_is_true:
name: parcels_quarantine_within_budget
expression: >
(select count(*) from {{ ref('stg_parcels_quarantine') }})
<= (select count(*) * 0.02 from {{ source('gis', 'parcels_raw') }})
config:
severity: warn # tighten to error once the feed is stable
columns:
- name: failure_category
tests:
- accepted_values:
values: ['null_geometry', 'wrong_srid', 'irreparable', 'repaired_but_rejected_by_policy']
Verify the alert fires as intended by running it against current data:
dbt test --select stg_parcels_quarantine
# a warn/error here means quarantine crossed the 2% budget — investigate the source
Wiring this warning into a failing CI check is covered in spatial testing in CI pipelines.
5. Optionally auto-repair and re-admit
When policy is repair, the repaired rows are already merged into the clean model by step 2 — but it is worth confirming what ST_MakeValid actually did, since it can change geometry type (a self-intersecting polygon may become a GeometryCollection). Normalize the repair output so downstream type expectations hold.
-- inside stg_parcels.sql, replace geom_repaired usage with a type-safe repair
ST_CollectionExtract(ST_MakeValid(geom), 3) as geometry -- 3 = keep polygonal parts only
Verify the repair preserved geometry type and did not silently empty the row:
select
count(*) filter (where ST_GeometryType(geometry) <> 'ST_Polygon') as type_changed,
count(*) filter (where ST_IsEmpty(geometry)) as emptied
from {{ ref('stg_parcels') }};
-- both should be 0 for a polygon feed
Configuration reference
| Parameter / key | Accepted values | Default | Notes |
|---|---|---|---|
invalid_geometry_policy |
repair / reject |
repair |
reject quarantines all invalid rows without merging repairs back |
failure_category |
null_geometry, wrong_srid, irreparable, repaired_but_rejected_by_policy |
— | Machine-readable triage tag; asserted with accepted_values |
| quarantine budget | fraction of source rows | 0.02 |
Threshold for the expression_is_true alert; tighten as the feed stabilizes |
ST_CollectionExtract type |
1 point, 2 line, 3 polygon |
— | Keeps only the expected part after ST_MakeValid changes type |
| base model materialization | ephemeral / table |
ephemeral |
table if you want the classification itself queryable for audit |
Gotchas & edge cases
ST_MakeValidchanges geometry type. Repairing a self-intersecting polygon can yield aGeometryCollectionof polygon plus stray lines. Always follow repair withST_CollectionExtract(..., 3)for a polygon feed so a downstreamgeometry(Polygon)contract still holds.- SRID stamped after validation routes everything to quarantine. Stamp or transform the SRID before the validity classification, or every row trips the
wrong_sridbranch. UseST_Transformto reproject andST_SetSRIDonly to label a known-but-unstamped frame. - Quarantine table grows unbounded. Persisted quarantine tables accumulate. Add a
quarantined_attimestamp (as above) and periodically prune or snapshot resolved rows, or the table becomes its own data-quality problem. - DuckDB has no
ST_IsValidReason. Thetarget.typebranch substitutes thefailure_categorytag on non-PostGIS engines, so triage still works, just with less coordinate-level detail. Run the reason-carrying leg on PostGIS if you need the fault location. - Repaired rows re-fail downstream. A row
ST_MakeValidmarks valid can still breakST_Unionon near-duplicate vertices. If topology ops fail on repaired input, addST_SnapToGridbefore re-admitting, or send borderline repairs back to quarantine for review.
FAQ
Should I quarantine invalid geometries or just fail the build?
It depends on cost of delay versus cost of a wrong repair. Failing the build with a generic test is right when any invalid geometry is a hard defect that must be fixed at source before the pipeline proceeds. Quarantine is right when valid data must keep flowing while invalid rows are set aside for review — a high-volume feed where blocking every downstream model over one bad row is unacceptable. Many projects do both: quarantine the rows and alert when the quarantine share crosses a budget.
How do I make sure a row lands in exactly one path?
Classify every row once in a single upstream model, then derive the clean and quarantine models as complementary filters of that classification. If the two paths compute ST_IsValid independently with slightly different predicates, a row can fall into neither or both. A count check — clean plus quarantined equals source — verifies the partition holds on every run.
Is it safe to auto-repair with ST_MakeValid in staging?
For approximate or high-volume data, yes — ST_MakeValid deterministically fixes self-intersections and unclosed rings. For legal, cadastral, or regulatory boundaries it is risky, because repair can move a vertex or split a polygon, changing the recorded shape. Run those feeds in reject mode so invalid rows are quarantined for a human decision rather than silently altered.
How do I alert someone when quarantine grows?
Attach a dbt_utils.expression_is_true test that compares the quarantine row count to a fraction of source rows, set to warn while a feed stabilizes and error once it is stable. Run it under dbt build in CI so a spike from an upstream export change trips the check on the pull request, and route the failure to your team’s normal CI notification channel.
Related
- Geometry Validation & Data Quality — the full staging validity sweep this quarantine split completes.
- Testing Geometry Validity with dbt Generic Tests — the hard pass/fail gate alternative to quarantining.
- Automating CRS Conversions in dbt Pipelines — fix wrong-SRID rows before they reach the quarantine branch.
Up: Part of Geometry Validation & Data Quality.