Quarantining invalid records with a dead-letter queue
When a record fails validation, you have two bad options and one good one. Dropping it loses data; blocking the pipeline on it stalls every good record behind it. The good option is a dead-letter queue: divert the failure, keep the stream moving, and hold the bad record for inspection and replay. This guide builds that quarantine sink, implementing a core mechanism of Data Quality & Schema Contracts in the Data Ingestion & OTA API Integration Workflows pillar.
Prerequisites
- Python 3.11+
- Standard library
json,logging,dataclasses,datetime - A validation function that returns a typed record or a structured error, as built for the schema contract
- A durable store for quarantined items (a table or queue; a JSONL file stands in here)
- A replay path that can re-run validation after a contract fix
Step 1 — Define the dead-letter envelope
A quarantined item is worthless without context. Wrap the raw payload with the reason it failed, the contract version in force, and a timestamp, so a later reviewer can diagnose and replay it.
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
logger = logging.getLogger("ingestion.dlq")
@dataclass(frozen=True)
class DeadLetter:
raw_payload: dict
reason: str
contract_version: str
quarantined_at: str # ISO-8601 UTC
@staticmethod
def create(raw_payload: dict, reason: str, contract_version: str,
now: datetime) -> "DeadLetter":
return DeadLetter(
raw_payload=raw_payload,
reason=reason,
contract_version=contract_version,
quarantined_at=now.astimezone(timezone.utc).isoformat(),
)
Step 2 — Route failures to the queue without stopping the batch
Process a batch by validating each record and diverting failures, so one malformed row never blocks the rest. The good records go forward; the bad ones land in the dead-letter sink with their reason attached.
def process_batch(records: list[dict], validate, contract_version: str,
now: datetime) -> tuple[list, list[DeadLetter]]:
accepted, dead = [], []
for raw in records:
try:
accepted.append(validate(raw))
except ValueError as exc:
letter = DeadLetter.create(raw, str(exc), contract_version, now)
dead.append(letter)
logger.warning("dead-lettered %s: %s", raw.get("property_id"), exc)
if dead:
logger.info("batch committed %d, quarantined %d", len(accepted), len(dead))
return accepted, dead
def persist_dead_letters(dead: list[DeadLetter], path: str) -> None:
with open(path, "a", encoding="utf-8") as fh:
for letter in dead:
fh.write(json.dumps(asdict(letter)) + "\n")
Step 3 — Replay after a contract fix
The point of quarantine, versus deletion, is recovery. When a contract bug is fixed or a provider reverts a change, re-run validation over the queue and promote whatever now passes, leaving the rest for the next fix.
def replay(path: str, validate) -> tuple[list, int]:
recovered, still_bad = [], 0
with open(path, encoding="utf-8") as fh:
for line in fh:
letter = json.loads(line)
try:
recovered.append(validate(letter["raw_payload"]))
except ValueError:
still_bad += 1
logger.info("replay recovered %d, still invalid %d", len(recovered), still_bad)
return recovered, still_bad
Replay is what turns a data-quality incident into a recoverable event: after you ship the contract fix, the backlog drains itself instead of being lost.
Verification and testing
from datetime import datetime
def _validate(rec: dict) -> dict:
if rec.get("base_rate_cents", 0) <= 0:
raise ValueError("non-positive rate")
return rec
def test_batch_splits_and_replays(tmp_path) -> None:
now = datetime(2026, 8, 1, tzinfo=timezone.utc)
batch = [{"property_id": "h1", "base_rate_cents": 18000},
{"property_id": "h2", "base_rate_cents": 0}] # invalid
accepted, dead = process_batch(batch, _validate, "v3", now)
assert len(accepted) == 1 and len(dead) == 1
path = tmp_path / "dlq.jsonl"
persist_dead_letters(dead, str(path))
# after a "fix", the same record still fails; replay reports it
recovered, still_bad = replay(str(path), _validate)
assert recovered == [] and still_bad == 1
Common pitfalls and edge cases
- Silent drops. Catching the error and moving on without persisting loses the record; always write it to the durable sink.
- No reason captured. A quarantined payload without its failure reason is un-diagnosable; store the exact validation error.
- Missing contract version. Without the version in force, you cannot tell whether a fix should recover the item; stamp it.
- Unbounded growth. A queue that only fills signals an unaddressed upstream break; alert on depth and oldest-item age.
- Replay without idempotency. Re-promoting recovered records must use the same idempotent upsert as normal ingestion, or replay duplicates data.
Related
- Data Quality & Schema Contracts — the parent cluster whose gate produces the failures this queue holds.
- Detecting schema drift in OTA payloads — the upstream signal that often explains a sudden quarantine spike.
- Rate Limiting & Retry Strategies — the retry discipline that precedes dead-lettering a transient failure.