This guide is part of Validating Spatial Data Quality in Pipelines, within the broader Automated Vector & Raster Cleaning Workflows reference.

Quarantining Invalid Features to a Dead-Letter Table

When a validation gate rejects a row, three things can happen to it: it is dropped, it takes the whole batch down with it, or it is set aside somewhere it can be examined and replayed. Only the third is operable.

Why Dropping Rejected Rows Is the Expensive Option

  • A missing feature is invisible. Nobody notices the parcel that was silently discarded until someone asks why a district’s totals are short.
  • The failure cannot be diagnosed later. Without the offending geometry, an investigation has only a count, and a count does not explain a cause.
  • Fixes cannot be verified. A repair rule written after the fact needs the original input to prove that it works.
  • Aborting instead is worse at scale. Failing a hundred-thousand-row batch because eight rows are malformed converts a small data problem into a pipeline outage.

Version and Environment Compatibility

Component Version Note
GeoPandas >=1.0 to_parquet with partitioning through pyarrow
pyarrow >=14 Partitioned dataset writes; predicate pushdown on read
Shapely >=2.0 to_wkb over an array, explain_validity per geometry
PostGIS >=3.3 Optional relational sink with ON CONFLICT replay support
pip install "geopandas>=1.0" "pyarrow>=14" "shapely>=2.0"

What a Dead-Letter Row Has to Carry

The four blocks of a quarantine record A quarantine record divided into four groups of columns. Identity names the feature and its source. Evidence holds the original geometry as well-known binary plus the original attributes as JSON. Reason holds the failing check names and the GEOS validity message. Context holds the run identifier, the code version and the quarantine timestamp used for partitioning and expiry. identity feature_id source_uri layer_name which row, from where evidence geometry_wkb attributes_json source_crs exactly as it arrived reason failed_checks validity_message severity why it was rejected context run_id code_version quarantined_at replay and expiry

The quarantine_features Recipe

from __future__ import annotations

import json
import logging
from datetime import datetime, timezone
from pathlib import Path

import geopandas as gpd
import pandas as pd
import shapely

logger = logging.getLogger(__name__)


def quarantine_features(
    failures: gpd.GeoDataFrame,
    dead_letter_root: Path | str,
    run_id: str,
    source_uri: str,
    layer_name: str,
    code_version: str,
    id_column: str = "parcel_id",
    reason_column: str = "failed_checks",
) -> int:
    """Write rejected features to a partitioned dead-letter dataset.

    The geometry is stored as WKB rather than as a geometry column, because a
    quarantine table must be able to hold geometry that no reader can parse.
    """
    if failures.empty:
        logger.info("no features quarantined for run %s", run_id)
        return 0

    geoms = failures.geometry.values
    attribute_columns = [c for c in failures.columns
                         if c not in {failures.geometry.name, reason_column}]

    records = pd.DataFrame({
        "feature_id": failures[id_column].astype(str).to_numpy(),
        "layer_name": layer_name,
        "source_uri": source_uri,
        "geometry_wkb": shapely.to_wkb(geoms, include_srid=False),
        "source_crs": str(failures.crs),
        "attributes_json": [
            json.dumps({k: _jsonable(v) for k, v in row.items()})
            for row in failures[attribute_columns].to_dict("records")
        ],
        "failed_checks": failures.get(reason_column, "unspecified"),
        "validity_message": [
            shapely.is_valid_reason(geom) if geom is not None else "missing geometry"
            for geom in geoms
        ],
        "run_id": run_id,
        "code_version": code_version,
        "quarantined_at": datetime.now(timezone.utc),
    })
    records["quarantine_date"] = records["quarantined_at"].dt.date.astype(str)

    records.to_parquet(
        Path(dead_letter_root),
        partition_cols=["layer_name", "quarantine_date"],
        index=False,
    )
    logger.warning("quarantined %d features from %s (run %s)", len(records), layer_name, run_id)
    return len(records)


def _jsonable(value):
    """Coerce pandas and numpy scalars into something json.dumps accepts."""
    if pd.isna(value):
        return None
    if hasattr(value, "isoformat"):
        return value.isoformat()
    if hasattr(value, "item"):
        return value.item()
    return value

Key Implementation Notes

  • Geometry is stored as WKB in a binary column, not as a geometry column. A quarantine table has to accept geometry that a strict reader would reject, and a typed geometry column can refuse the write — which loses exactly the row you most needed.
  • is_valid_reason is captured per row. The GEOS message is the difference between “380 invalid geometries” and “380 ring self-intersections at one shared boundary”, and only the second suggests a cause.
  • Attributes are serialised to JSON rather than kept as columns. Source schemas differ and drift; one JSON column absorbs that without the quarantine table needing a migration every time an upstream layer changes.
  • Partitioning is by layer and quarantine date. Expiry becomes a partition drop, and a replay for one layer on one day reads one directory.
  • The function returns a count so the caller can compute the rate and publish it as a metric — the rate, not the count, is what a threshold should watch.
  • Nothing is repaired here. Repair is a separate decision made against the evidence, and mixing it into the quarantine write destroys the evidence.
The replay loop over a quarantine partition A replay job reads one layer-and-date partition of the dead-letter dataset, reconstructs geometries from the stored well-known binary, applies the corrected transform, and re-runs validation. Rows that now pass are upserted into the main table on their stable key. Rows that still fail are written back with an incremented attempt count so repeated failures become visible. one partition layer + date apply the fix from_wkb → transform re-validate same schema upsert main table re-queue attempt + 1 The attempt counter is what stops a permanently broken row being replayed nightly forever — past three, it needs a person, not another run.

Turning the Rate Into a Signal

The count of quarantined rows is nearly meaningless on its own, because batch sizes vary. The rate is the number worth watching, and it deserves three pieces of treatment.

Alerting on the trend as well as the level Quarantine rate plotted over fourteen nightly runs. The first eleven sit in a narrow band near one tenth of a percent. The last three double each night, crossing the half-percent threshold only on the final run, by which point the rise has been visible for two nights to anyone watching the derivative. quarantine rate · 14 nightly runs threshold 0.5% visible here alerts here A rule on the level alone waits two nights longer than a rule that also watches the rate of change.

Publish it as a metric, not a log line, with the layer and the failing check as labels. That makes “which check is driving the increase?” a query rather than an investigation.

Threshold it against a measured baseline. A layer that normally quarantines 0.1% and today quarantines 0.4% is worth a look; the same 0.4% on a layer that normally sits at 0.35% is noise. The threshold belongs in the same configuration as the severity mapping.

Alert on the derivative as well as the level. A rate that has doubled every night for three nights will cross any fixed threshold eventually, and catching it on night two rather than night five is the difference between a question for the publisher and a backfill.

Troubleshooting the Quarantine Path

Symptom Likely cause Fix
Write fails on the geometry column Sink rejects invalid geometry Store WKB in a binary column, as the recipe does
Quarantine table grows without bound No retention policy Partition by date and drop partitions past the window
Replay re-quarantines the same rows nightly No attempt counter Increment on re-queue and escalate past a threshold
Rate metric is meaningless Count published instead of rate Divide by the batch size at the point of emission
Attributes lost on write Schema mismatch between batches Serialise attributes to a single JSON column

Integration Note

The quarantine write sits immediately after validation, taking the failures frame produced in writing geometry validity assertions with pandera and returning a count the caller turns into a metric. In an orchestrated pipeline the replay is its own scheduled job rather than a step in the main DAG, so that a stuck replay cannot delay the nightly load — the separation argued for in orchestrating spatial ETL pipelines.

Choosing the Sink for Quarantined Rows

Three sinks are common, and the right one depends on who investigates the failures rather than on where the main data lives.

Partitioned Parquet on object storage is the default. It costs nothing to write, holds arbitrary attribute shapes in a JSON column, and a replay reads exactly one partition. Its weakness is interactive investigation: querying it means opening a notebook or pointing an engine at it.

A relational table beside the target suits pipelines whose failures are examined by the same people who query the serving data. Investigation is a SELECT away, and the join back to the main table is trivial. The cost is schema rigidity — a new source with different attributes needs a migration unless the attributes go into a JSON column here too.

A message queue suits streaming ingestion where the replay is itself a consumer. It fits the architecture, but nothing about it supports the question “how many features did this layer quarantine last month”, which is the question most often asked.

Whatever the sink, keep it separate from the serving table. Quarantined rows in the same table behind a status flag inevitably leak into a query that forgot the filter, and a rejected geometry that reaches a dashboard is worse than one that never arrived.