Storing Failed Geometries in a PostGIS Dead-Letter Queue

A dead-letter table for geometries has one property that separates it from every other table in the schema: it must accept data that is invalid. No CHECK (ST_IsValid(geom)), no SRID-typed column, no NOT NULL on the geometry — because the rows arriving are precisely the ones the strict tables refused. Store the geometry as it arrived, in WKB, tagged with whatever CRS the source claimed, and put the strictness in the queries you run against it rather than in the constraints that would have discarded the evidence.

When to Use This Pattern

  • Your loader rejects individual features for validity, projection or topology reasons and you want the rest of the batch to land.
  • The failures need to be looked at spatially — plotted on a map, joined to municipality boundaries, compared against the tile grid — which is exactly what a database and not a log file gives you.
  • Re-driving is expected, so the stored row has to be complete enough to reconstruct the original task input.
  • You already run PostGIS. If you do not, an object-store queue of newline-delimited GeoJSON plus a metadata table is the equivalent design; you lose the spatial queries, which is most of the point.
Why the queue needs the opposite constraintsA geometry type constraint, an SRID constraint, a validity check and a not-null constraint each reject the failing feature in the main table. The dead-letter table relaxes all four.parcelsgeometry_dlqgeometry typeMultiPolygon only — rejectsuntyped — acceptsSRID25832 only — rejectsany, including 0 — acceptsvalidityCHECK ST_IsValid — rejectsrecorded, not enforcednull geometryNOT NULL — rejectsnullable, WKB keptEvery constraint that protects the main table would destroy the evidence in the queue.
Read this table the other way round and it is a checklist: any constraint you are tempted to add to the queue is one you should be recording as a column instead.

Complete Working Example

The table below is deliberately permissive. Everything that would normally be enforced by a constraint is instead recorded as a fact, so a triage query can ask about it later.

CREATE TABLE geometry_dlq (
    id            bigserial PRIMARY KEY,
    work_key      text        NOT NULL,      -- the pipeline's idempotency key
    source_uri    text        NOT NULL,
    feature_id    text,                      -- the source's own identifier, if any
    -- Untyped, unvalidated, nullable ON PURPOSE. This column exists to hold the
    -- geometries that a geometry(MultiPolygon, 25832) NOT NULL column threw out.
    geom          geometry,
    geom_wkb      bytea,                     -- the raw bytes, even when geom will not parse
    declared_crs  text,                      -- what the source SAID, verbatim
    detected_crs  text,                      -- what the coordinate ranges suggest
    validity      text,                      -- ST_IsValidReason() output, or NULL
    failure_class text        NOT NULL,
    error_text    text,
    params        jsonb       NOT NULL DEFAULT '{}'::jsonb,
    attempts      integer     NOT NULL DEFAULT 1,
    failed_at     timestamptz NOT NULL DEFAULT now(),
    resolved_at   timestamptz,
    UNIQUE (work_key, feature_id)
);

-- A GiST index still works on a mixed-SRID column, but only within one SRID at a
-- time; triage queries must filter by declared_crs before using it spatially.
CREATE INDEX geometry_dlq_gist ON geometry_dlq USING gist (geom)
    WHERE geom IS NOT NULL AND resolved_at IS NULL;

CREATE INDEX geometry_dlq_open ON geometry_dlq (failure_class, failed_at)
    WHERE resolved_at IS NULL;

The write path has to survive geometries that will not even parse, which means attempting the structured column and falling back to the raw bytes.

from __future__ import annotations

import json
from typing import Any, Mapping, Optional

import psycopg
import shapely
from shapely.errors import GEOSException, ShapelyError

INSERT = """
INSERT INTO geometry_dlq
       (work_key, source_uri, feature_id, geom, geom_wkb, declared_crs,
        detected_crs, validity, failure_class, error_text, params)
VALUES (%(work_key)s, %(source_uri)s, %(feature_id)s,
        CASE WHEN %(wkb)s IS NULL THEN NULL
             ELSE ST_SetSRID(ST_GeomFromWKB(%(wkb)s), %(srid)s) END,
        %(wkb)s, %(declared_crs)s, %(detected_crs)s, %(validity)s,
        %(failure_class)s, %(error_text)s, %(params)s::jsonb)
ON CONFLICT (work_key, feature_id) DO UPDATE
   SET attempts = geometry_dlq.attempts + 1,
       error_text = EXCLUDED.error_text,
       failed_at = now()
"""


def detect_crs(geom: Any) -> Optional[str]:
    """A cheap sanity read of the coordinate ranges — not a substitute for metadata."""
    try:
        minx, miny, maxx, maxy = geom.bounds
    except (AttributeError, ValueError):
        return None
    if -180.5 <= minx and maxx <= 180.5 and -90.5 <= miny and maxy <= 90.5:
        return "looks-geographic"
    if 100_000 <= minx and maxx <= 1_000_000 and 6_000_000 <= miny:
        return "looks-utm-northern"
    return "unknown"


def dead_letter_geometry(
    conn: psycopg.Connection,
    work_key: str,
    source_uri: str,
    feature_id: Optional[str],
    geom: Any,
    declared_crs: str,
    failure_class: str,
    error_text: str,
    params: Mapping[str, Any],
    srid: int = 0,
) -> None:
    """Store one failed feature with everything needed to reproduce it."""
    wkb: Optional[bytes] = None
    validity: Optional[str] = None
    try:
        wkb = shapely.to_wkb(geom)
        # is_valid_reason answers "why not", which is the column triage groups on.
        validity = None if shapely.is_valid(geom) else shapely.is_valid_reason(geom)
    except (GEOSException, ShapelyError, TypeError):
        # Unserialisable geometry: keep the row, lose only the geometry column.
        validity = "unserialisable"

    with conn.cursor() as cur:
        cur.execute(
            INSERT,
            {
                "work_key": work_key,
                "source_uri": source_uri,
                "feature_id": feature_id,
                "wkb": wkb,
                "srid": srid,                       # 0 = unknown, and that is allowed here
                "declared_crs": declared_crs,
                "detected_crs": detect_crs(geom),
                "validity": validity,
                "failure_class": failure_class,
                "error_text": error_text[:2000],
                "params": json.dumps(dict(params), sort_keys=True),
            },
        )

Two decisions in that code are worth naming. srid = 0 is used when the source’s CRS cannot be resolved to an EPSG code, which PostGIS accepts and which keeps the row storable; the human-readable declared_crs string carries the real information. And geom_wkb duplicates the geometry deliberately — when ST_GeomFromWKB itself fails, the geom column is null and the bytes are still there for someone to inspect with shapely or ogrinfo.

What the write path preservesA rejected feature is stored with its parsed geometry where possible, its raw well-known-binary bytes always, the CRS the source declared, the CRS the coordinate ranges suggest, and the validity reason.rejected featureself-intersecting ringgeom — parsed if possibleindexed, queryable by extentgeom_wkb — alwayssurvives an unparseable shapedeclared vs detected CRSthe disagreement is the cluevalidity — ST_IsValidReasonone row, no constrainttriage and re-drive both possible
Every column here exists because a constraint elsewhere would have thrown the row away. The queue’s job is to be the one place in the schema that does not.

Parameter & Option Reference

Column Type Nullable Spatial notes
geom geometry yes Untyped and unvalidated. A typed column would reject the geometries this table exists to keep.
geom_wkb bytea yes The raw bytes, kept even when geom is null — the only record of a shape GEOS refuses to parse.
declared_crs text yes Verbatim from the source: EPSG:25833, a PROJ string, or the empty string when the .prj was missing.
detected_crs text yes A coordinate-range heuristic, useful only as a hint. Never re-project on the strength of it.
validity text yes ST_IsValidReason() output — Self-intersection[6.12 58.4] names the offending vertex.
srid argument int 0 means unknown, which PostGIS stores happily and which is honest. Do not default it to 4326.
params jsonb no Everything the task consumed: resampling, nodata, target CRS, GDAL version.

Verification & Testing

The test that matters is that a geometry the main table rejects is still storable here.

import shapely


def test_dlq_accepts_what_the_main_table_rejects(conn) -> None:
    # A classic bow-tie: valid WKB, invalid polygon.
    bowtie = shapely.from_wkt("POLYGON((0 0, 2 2, 2 0, 0 2, 0 0))")
    assert not shapely.is_valid(bowtie)

    dead_letter_geometry(
        conn, work_key="k1", source_uri="s3://drop/parcels.gpkg", feature_id="42",
        geom=bowtie, declared_crs="EPSG:25832", failure_class="invalid_geometry",
        error_text="ST_MakeValid produced an empty geometry", params={"nodata": None},
        srid=25832,
    )

    with conn.cursor() as cur:
        cur.execute("SELECT validity, ST_SRID(geom) FROM geometry_dlq WHERE work_key = 'k1'")
        validity, srid = cur.fetchone()
    assert "Self-intersection" in validity
    assert srid == 25832

Triage is where the table earns its keep, and it is all ordinary SQL. The two queries below answer “what is broken?” and “where is it broken?” respectively:

-- What is broken, and is it one source or many?
SELECT failure_class, count(*), count(DISTINCT source_uri) AS sources,
       min(failed_at) AS first_seen
FROM   geometry_dlq
WHERE  resolved_at IS NULL
GROUP  BY failure_class
ORDER  BY 2 DESC;

-- Where is it broken? Failures that cluster in one municipality are a data
-- problem; failures spread evenly are a code problem.
SELECT m.name, count(*) AS failures
FROM   geometry_dlq d
JOIN   municipalities m ON ST_Intersects(m.geom, ST_Transform(d.geom, 25833))
WHERE  d.resolved_at IS NULL
  AND  d.declared_crs = 'EPSG:25832'      -- one SRID at a time on a mixed column
GROUP  BY m.name
ORDER  BY 2 DESC
LIMIT  10;
Clustered failures mean data, scattered failures mean codeOn the left the failed features concentrate in one administrative area. On the right they are spread evenly across the whole extent.clustered — one municipality’s exportfix the source, re-drive oncescattered — every source, evenlyfix the code; re-driving first changes nothingThe same count of dead letters, two completely different tickets.
This is the query a log file cannot answer, and it is the reason the queue lives in PostGIS rather than in an object store.

Common Pitfalls

  • Putting a validity CHECK on the queue. The single most self-defeating mistake available here: the constraint rejects the invalid geometry, the capture raises, and the payload is lost at exactly the moment it mattered. The queue is the one table that must accept everything.
  • Typing the geometry column. geometry(MultiPolygon, 25832) rejects a GEOMETRYCOLLECTION produced by a failed ST_MakeValid, and rejects anything whose CRS could not be resolved. Leave the column untyped and record the type as data.
  • Defaulting an unknown SRID to 4326. It is a lie that survives into the re-drive, and it turns “we do not know the CRS” into “we believe it is WGS84” — which then places UTM coordinates somewhere in the Gulf of Guinea. ST_SRID = 0 is the honest value.
  • Indexing a mixed-SRID column and expecting it to work across SRIDs. PostGIS will not compare geometries with different SRIDs, and a GiST index over a mixed column serves each SRID separately. Always filter to one declared_crs before a spatial predicate.
  • Storing only the reprojected geometry. If the pipeline transformed before failing, storing the transformed shape hides the original coordinates, which are the evidence for a CRS validation bug. Store what arrived.

Frequently Asked Questions

How big does this table get?

Smaller than you expect, because it holds failures rather than data — a well-behaved vector pipeline dead-letters a few hundred features a week, which is single-digit megabytes a year. The pathology to watch is a systematic failure adding tens of thousands of rows overnight; that is what the depth alert in alerting on dead-letter queue growth exists to catch.

Should raster failures go in the same table?

Give them their own, because the payload is different in kind: a raster failure stores a source URI and a window, not a geometry, and mixing the two makes both sets of columns half-null. What should be shared is the work_key vocabulary, so a re-drive flow can handle either.

Can I attach the queue to a map for triage?

Yes, and it is worth doing. A read-only view filtered to resolved_at IS NULL and one declared_crs, published through pg_featureserv or straight into QGIS, turns triage into looking at a map. Be careful with permissions — see securing PostGIS connections in workflows, since this table can contain records that were excluded from the published dataset.

What resolves an entry?

Either a successful re-drive, or a deliberate decision to abandon it. Both set resolved_at; only the second needs a reason recorded. Leaving entries unresolved because “we will get to them” is how a queue becomes a landfill, and the depth metric stops meaning anything.

Dead-Letter Queues for Failed Geotasks