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.
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.
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;
Common Pitfalls
- Putting a validity
CHECKon 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 aGEOMETRYCOLLECTIONproduced by a failedST_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 = 0is 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_crsbefore 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.
Related
- Dead-letter queues for failed geotasks — the pattern this table implements
- Reprocessing dead-letter geotasks safely — draining the queue without duplicating work
- Repairing invalid geometries before load — stopping the entries from being written in the first place
- Making PostGIS upserts idempotent for feature loads — the load path these features fell out of