Bad data is inevitable — silent data loss isn't. Learn how to design and implement pipeline quarantine zones that isolate failed records, preserve full diagnostic context, and enable safe replay without stalling your production workflows. This lesson covers the complete architecture: routing logic, metadata schemas, inspection workflows, idempotent replay, and SLA-driven alerting.

It's 2:47 AM on a Tuesday, and your on-call rotation is about to ruin someone's night. A partner system started sending malformed JSON three hours ago — missing required fields, mismatched types, timestamps in a format your parser has never seen. Your pipeline dutifully ingested every record, failed on transformation, and has been retrying ever since. By the time the alert fires, the queue is backed up 400,000 records deep. The data your downstream analytics team needs for their 9 AM report doesn't exist. And the bad records that caused all of this? Still sitting in the queue, still blocking everything behind them.
This is the scenario that quarantine zones are designed to prevent. The core idea is deceptively simple: when a record fails validation or transformation, don't retry it forever and don't drop it on the floor. Route it sideways — into an isolated storage area where it can't block production, where you can inspect it safely, enrich it with diagnostic metadata, and replay it when you're ready. In practice, building a quarantine system that's actually useful requires careful thought about isolation boundaries, metadata capture, replay semantics, and operational ergonomics.
By the end of this lesson, you'll have a complete mental model and practical implementation blueprint for pipeline quarantine zones that work in production at scale. We'll cover not just the mechanics of routing bad data, but the deeper design questions: how fine-grained should your quarantine be, what metadata must you capture to make records replayable, how do you prevent quarantine zones from becoming a data graveyard, and how do you wire up the inspection and replay workflows that make the whole system useful.
What you'll learn:
This lesson assumes you're comfortable with the fundamentals of data pipeline architecture and have hands-on experience with at least one orchestration tool like Airflow. You should understand basic data quality validation concepts and be familiar with error handling patterns in data pipelines. Python code examples use standard libraries plus pydantic, boto3, and psycopg2 — familiarity with these is helpful but not required to follow the concepts.
Before writing a single line of quarantine code, you need a clear taxonomy of the failures you're trying to handle. Not all failures belong in quarantine, and mixing them together is one of the most common design mistakes in production pipelines.
There are three fundamentally different categories of pipeline failures:
Transient infrastructure failures are temporary and self-resolving: network timeouts, database connection drops, rate limit responses from upstream APIs, temporary lock contention. These should be handled with retry logic and exponential backoff before records ever reach quarantine. Routing transient failures to quarantine creates false positives that pollute your inspection queue and make it harder to identify genuine data quality issues.
Persistent infrastructure failures are systemic but not data-related: a destination database schema change that breaks every record equally, a misconfigured credential, a downstream service that's completely unavailable. These should trigger a circuit breaker or pipeline halt rather than individual record quarantine. When every record fails for the same infrastructure reason, you don't want 10 million quarantined records — you want one operational alert.
Data quality failures are the genuine target for quarantine: records that are individually malformed or semantically invalid independent of infrastructure state. Schema violations, referential integrity failures, business rule violations, encoding errors, implausible values. These are the records that need isolation, inspection, and potentially manual remediation before reprocessing.
The boundary between these categories isn't always obvious. A record that fails because a required foreign key doesn't exist in the reference table yet — is that a data quality issue or a timing issue from an out-of-order arrival? A timestamp that's formatted differently than your parser expects — is that a schema evolution event or a genuinely malformed record? Getting these classifications right matters because they determine what your quarantine zone is for.
Key insight
Design your failure taxonomy first, then implement routing logic that maps each failure category to the appropriate response: retry, circuit break, or quarantine. Quarantine is for records that require human or automated inspection, not for failures that resolve themselves.
A production quarantine zone isn't a single storage location — it's a multi-layer architecture with different isolation levels serving different purposes. Think of it like a hospital triage system: there's initial assessment, holding for observation, and specialized treatment units.
The hot quarantine buffer is where records land immediately upon failure. It needs to be fast, durable, and non-blocking relative to your production pipeline. In practice, this means a separate message queue partition or a dedicated fast-write storage layer — not the same database table your production data lives in.
For stream processing pipelines, this maps naturally to a dedicated Kafka topic (or Kinesis stream) for failed records. For batch pipelines, it's typically a separate path in your cloud object storage — s3://your-data-lake/quarantine/hot/ with sub-paths by pipeline and date. The critical property here is write performance: quarantining a record must never be slower than processing it successfully, otherwise your quarantine mechanism itself becomes a bottleneck.
After initial capture in the hot buffer, records get promoted to the inspection store — a queryable, indexed storage layer where engineers can actually examine failed records and diagnose their issues. This is where you need rich metadata attached to each quarantined record, and where the data format matters for usability.
A flat-file dump in cloud storage is not an inspection store — it's a graveyard. A real inspection store needs:
Warning
Don't use your production data warehouse as your inspection store. Schema conflicts, permission complications, and the temptation to run expensive analytical queries against quarantined records all create operational friction. Keep the inspection store separate, even if it's just a PostgreSQL database or a dedicated Elasticsearch index.
The replay queue is where records go when they're ready for reprocessing — after inspection has determined the root cause and either the data has been corrected, the pipeline logic has been fixed, or both. The replay queue is architecturally separate from both the hot buffer and the inspection store because its operational characteristics are different: it needs to integrate with your existing pipeline orchestration, support controlled throughput throttling, and provide exactly-once delivery semantics.
Here's what this architecture looks like in practice:
Production Pipeline
│
▼
┌─────────────────┐
│ Validation & │
│ Transform │
│ Layer │
└────────┬────────┘
│
┌────┴────┐
│ │
▼ ▼
Success Failure
│ │
▼ ▼
Production Hot Quarantine
Sink Buffer
│
▼ (async promotion)
Inspection
Store
│
▼ (after remediation)
Replay Queue
│
▼ (rejoins pipeline)
Production
Pipeline
The asymmetry here is intentional: the success path is synchronous and low-latency, while the quarantine path can afford to be asynchronous. What you must not do is let the quarantine write path be in the critical section of your production processing loop.
The quarantine router is the component that intercepts failures and directs records to the appropriate destination. Its design needs to handle three concerns simultaneously: classification accuracy, metadata enrichment, and write reliability.
Here's a production-grade router implementation in Python:
import json
import uuid
import traceback
from datetime import datetime, timezone
from dataclasses import dataclass, field, asdict
from enum import Enum
from typing import Any, Optional
import boto3
from botocore.exceptions import ClientError
class FailureCategory(Enum):
SCHEMA_VIOLATION = "schema_violation"
TYPE_MISMATCH = "type_mismatch"
REFERENTIAL_INTEGRITY = "referential_integrity"
BUSINESS_RULE = "business_rule"
ENCODING_ERROR = "encoding_error"
UNKNOWN = "unknown"
@dataclass
class QuarantineMetadata:
"""
Rich metadata envelope for quarantined records.
Every field here exists to support one of three goals:
root-cause diagnosis, replay routing, or SLA tracking.
"""
quarantine_id: str = field(default_factory=lambda: str(uuid.uuid4()))
quarantine_timestamp: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
pipeline_id: str = ""
pipeline_run_id: str = ""
stage: str = "" # Which stage failed: ingest, validate, transform
failure_category: str = FailureCategory.UNKNOWN.value
failure_reason: str = "" # Human-readable description
exception_type: str = ""
exception_message: str = ""
stack_trace: str = ""
source_system: str = ""
source_partition: str = "" # Kafka partition, S3 prefix, etc.
source_offset: Optional[str] = None # Kafka offset, S3 key, etc.
retry_count: int = 0
is_replayable: bool = True # False for PII scrubbing failures, etc.
replay_attempted: bool = False
replay_timestamp: Optional[str] = None
remediation_note: Optional[str] = None # Filled in during inspection
schema_version: str = "" # Version of schema that was active at failure time
record_size_bytes: int = 0
tags: dict = field(default_factory=dict)
@dataclass
class QuarantinedRecord:
metadata: QuarantineMetadata
original_payload: Any # The raw record exactly as received
parsed_payload: Optional[Any] # Partially parsed version if available
class QuarantineRouter:
def __init__(
self,
pipeline_id: str,
hot_buffer_bucket: str,
hot_buffer_prefix: str = "quarantine/hot",
s3_client=None,
):
self.pipeline_id = pipeline_id
self.hot_buffer_bucket = hot_buffer_bucket
self.hot_buffer_prefix = hot_buffer_prefix
self.s3 = s3_client or boto3.client("s3")
def classify_failure(self, exception: Exception) -> FailureCategory:
"""
Map exception types to failure categories.
You'll extend this as you encounter new failure modes in production.
"""
exception_name = type(exception).__name__
message = str(exception).lower()
classification_rules = [
(["validationerror", "schemaerrror"], FailureCategory.SCHEMA_VIOLATION),
(["typeerror", "valueerror"], FailureCategory.TYPE_MISMATCH),
(["integriteerror", "foreignkeyviolation"], FailureCategory.REFERENTIAL_INTEGRITY),
(["unicodedecodeerror", "encodingerror"], FailureCategory.ENCODING_ERROR),
]
for exception_names, category in classification_rules:
if any(n.lower() in exception_name.lower() for n in exception_names):
return category
# Fall back to message-based classification
if any(word in message for word in ["required field", "missing", "null"]):
return FailureCategory.SCHEMA_VIOLATION
if any(word in message for word in ["business rule", "constraint", "invalid value"]):
return FailureCategory.BUSINESS_RULE
return FailureCategory.UNKNOWN
def quarantine(
self,
record: Any,
exception: Exception,
pipeline_run_id: str,
stage: str,
source_system: str,
source_partition: str = "",
source_offset: Optional[str] = None,
schema_version: str = "",
retry_count: int = 0,
parsed_partial: Optional[Any] = None,
additional_tags: Optional[dict] = None,
) -> str:
"""
Route a failed record to the hot quarantine buffer.
Returns the quarantine_id for tracking.
"""
failure_category = self.classify_failure(exception)
raw_payload = record if isinstance(record, (str, bytes)) else json.dumps(record, default=str)
record_size = len(raw_payload) if isinstance(raw_payload, str) else len(raw_payload.decode("utf-8", errors="replace"))
metadata = QuarantineMetadata(
pipeline_id=self.pipeline_id,
pipeline_run_id=pipeline_run_id,
stage=stage,
failure_category=failure_category.value,
failure_reason=self._extract_failure_reason(exception),
exception_type=type(exception).__name__,
exception_message=str(exception)[:2000], # Truncate very long messages
stack_trace=traceback.format_exc()[:5000],
source_system=source_system,
source_partition=source_partition,
source_offset=source_offset,
retry_count=retry_count,
schema_version=schema_version,
record_size_bytes=record_size,
tags=additional_tags or {},
)
quarantined = QuarantinedRecord(
metadata=metadata,
original_payload=raw_payload,
parsed_payload=parsed_partial,
)
self._write_to_hot_buffer(quarantined)
return metadata.quarantine_id
def _extract_failure_reason(self, exception: Exception) -> str:
"""
Generate a concise, human-readable failure reason.
This is what shows up in dashboards, so make it useful.
"""
exc_type = type(exception).__name__
message = str(exception)
# Truncate at first newline for readability in dashboards
message = message.split("\n")[0][:500]
return f"{exc_type}: {message}"
def _write_to_hot_buffer(self, record: QuarantinedRecord) -> None:
"""
Write to S3 hot buffer with a path structure that enables
efficient querying by pipeline, date, and failure category.
"""
meta = record.metadata
date_prefix = meta.quarantine_timestamp[:10] # YYYY-MM-DD
s3_key = (
f"{self.hot_buffer_prefix}/"
f"pipeline={meta.pipeline_id}/"
f"date={date_prefix}/"
f"category={meta.failure_category}/"
f"{meta.quarantine_id}.json"
)
payload = {
"metadata": asdict(meta),
"original_payload": record.original_payload,
"parsed_payload": record.parsed_payload,
}
try:
self.s3.put_object(
Bucket=self.hot_buffer_bucket,
Key=s3_key,
Body=json.dumps(payload, default=str),
ContentType="application/json",
# Tag for lifecycle policy management
Tagging=f"pipeline={meta.pipeline_id}&category={meta.failure_category}&replayable={meta.is_replayable}",
)
except ClientError as e:
# Critical: quarantine writes must not silently fail.
# Log to a secondary fallback location before re-raising.
self._write_to_fallback_log(record, e)
raise
Notice that the S3 key structure is deliberate: partitioning by pipeline, date, and failure category means you can use Athena or Spark to query across the quarantine zone without full scans. This matters when you have millions of quarantined records and need to identify patterns.
Tip
Always store the original_payload exactly as received — raw bytes if possible, before any parsing. This is your ground truth for replay. If you store only the partially-parsed version, you lose the ability to replay through a fixed parser, which is often the whole point.
The router is useless unless it's wired into your actual pipeline logic in a way that's clean, maintainable, and doesn't turn every transform function into a tangle of try/except blocks. Here's a decorator pattern that handles this cleanly:
import functools
from typing import Callable, Iterator, List, Tuple
def with_quarantine(
router: QuarantineRouter,
stage: str,
source_system: str,
pipeline_run_id: str,
schema_version: str = "",
max_retries: int = 2,
):
"""
Decorator that wraps a record-level transform function with quarantine routing.
Handles retry logic for transient errors before escalating to quarantine.
"""
def decorator(transform_fn: Callable):
@functools.wraps(transform_fn)
def wrapper(record, *args, **kwargs):
last_exception = None
for attempt in range(max_retries + 1):
try:
return transform_fn(record, *args, **kwargs)
except TransientError as e:
# Transient errors get retried; don't quarantine yet
last_exception = e
if attempt < max_retries:
time.sleep(2 ** attempt) # Simple backoff
continue
# Exhausted retries on transient error — now quarantine
except DataQualityError as e:
# Data quality errors go straight to quarantine
last_exception = e
break
except Exception as e:
# Unknown errors: quarantine after retries
last_exception = e
if attempt < max_retries:
continue
break
quarantine_id = router.quarantine(
record=record,
exception=last_exception,
pipeline_run_id=pipeline_run_id,
stage=stage,
source_system=source_system,
schema_version=schema_version,
retry_count=max_retries,
)
# Return None signals the calling loop to skip this record
return None
return wrapper
return decorator
class TransientError(Exception):
"""Network timeouts, temporary unavailability, rate limits."""
pass
class DataQualityError(Exception):
"""Schema violations, type mismatches, business rule failures."""
pass
# Usage in your actual pipeline:
def process_customer_events(
records: List[dict],
router: QuarantineRouter,
pipeline_run_id: str,
) -> Tuple[List[dict], int]:
"""
Process a batch of customer events, quarantining failures
without blocking the rest of the batch.
"""
@with_quarantine(
router=router,
stage="transform",
source_system="customer_events_kafka",
pipeline_run_id=pipeline_run_id,
schema_version="v2.3",
)
def transform_customer_event(record: dict) -> dict:
# Validate required fields
required = {"customer_id", "event_type", "occurred_at", "properties"}
missing = required - set(record.keys())
if missing:
raise DataQualityError(f"Missing required fields: {missing}")
# Type coercion with validation
try:
event = {
"customer_id": str(record["customer_id"]),
"event_type": validate_event_type(record["event_type"]),
"occurred_at": parse_timestamp(record["occurred_at"]),
"properties": validate_properties(record["properties"]),
"processed_at": datetime.now(timezone.utc).isoformat(),
"schema_version": "v2.3",
}
except (KeyError, ValueError, TypeError) as e:
raise DataQualityError(f"Transform failed: {e}") from e
return event
successes = []
quarantine_count = 0
for record in records:
result = transform_customer_event(record)
if result is not None:
successes.append(result)
else:
quarantine_count += 1
return successes, quarantine_count
This pattern has an important property: the pipeline always produces some output even when records fail. The calling code gets back only clean records plus a count of quarantined records. The pipeline run succeeds from an orchestration perspective — it doesn't get marked as failed just because some records were quarantined. This is what "not blocking production" actually means in practice.
Warning
Be very careful about how you define pipeline success when using quarantine zones. If your orchestration marks a pipeline run as "succeeded" regardless of quarantine volume, you can silently accumulate thousands of failed records with no alert. Set explicit thresholds: a run that quarantines more than 1% of records should trigger a warning; more than 5% should trigger a failure that stops the pipeline and pages on-call.
A quarantine zone without an inspection workflow is a data graveyard. The records accumulate, nobody looks at them, and six months later someone realizes that 40% of their historical data is quietly wrong because it was silently quarantined. The inspection workflow is what separates a real quarantine system from a sophisticated data disposal mechanism.
The hot buffer is for initial capture. For actual inspection, you need a promotion job that moves records from the hot buffer into a queryable store and enriches them with aggregated diagnostic information:
import psycopg2
from psycopg2.extras import execute_batch
from typing import Generator
def promote_to_inspection_store(
s3_client,
db_conn: psycopg2.extensions.connection,
bucket: str,
hot_prefix: str,
pipeline_id: str,
date: str,
) -> dict:
"""
Promote quarantined records from hot buffer to the queryable inspection store.
Run this as a scheduled job, 15-30 minutes after the main pipeline.
"""
promoted = 0
skipped = 0
errors = []
for record in _stream_hot_buffer(s3_client, bucket, hot_prefix, pipeline_id, date):
try:
_upsert_to_inspection_store(db_conn, record)
promoted += 1
except Exception as e:
errors.append({"quarantine_id": record["metadata"]["quarantine_id"], "error": str(e)})
skipped += 1
db_conn.commit()
return {
"promoted": promoted,
"skipped": skipped,
"errors": errors,
"pipeline_id": pipeline_id,
"date": date,
}
def _upsert_to_inspection_store(conn, record: dict) -> None:
meta = record["metadata"]
sql = """
INSERT INTO quarantine_inspection (
quarantine_id,
pipeline_id,
pipeline_run_id,
quarantine_timestamp,
stage,
failure_category,
failure_reason,
exception_type,
exception_message,
source_system,
source_partition,
source_offset,
retry_count,
is_replayable,
replay_attempted,
schema_version,
record_size_bytes,
tags,
original_payload_ref,
remediation_note
) VALUES (
%(quarantine_id)s, %(pipeline_id)s, %(pipeline_run_id)s,
%(quarantine_timestamp)s, %(stage)s, %(failure_category)s,
%(failure_reason)s, %(exception_type)s, %(exception_message)s,
%(source_system)s, %(source_partition)s, %(source_offset)s,
%(retry_count)s, %(is_replayable)s, %(replay_attempted)s,
%(schema_version)s, %(record_size_bytes)s, %(tags)s,
%(original_payload_ref)s, %(remediation_note)s
)
ON CONFLICT (quarantine_id) DO UPDATE SET
replay_attempted = EXCLUDED.replay_attempted,
remediation_note = EXCLUDED.remediation_note;
"""
# Store original payload reference (S3 path), not the payload itself
# This keeps the inspection store lean while raw data stays in object storage
s3_key = f"quarantine/hot/pipeline={meta['pipeline_id']}/date={meta['quarantine_timestamp'][:10]}/category={meta['failure_category']}/{meta['quarantine_id']}.json"
with conn.cursor() as cur:
cur.execute(sql, {
**meta,
"tags": json.dumps(meta.get("tags", {})),
"original_payload_ref": s3_key,
})
Now engineers can run SQL queries against the inspection store to understand failure patterns:
-- Which failure categories are most common today?
SELECT
failure_category,
source_system,
COUNT(*) as record_count,
COUNT(DISTINCT pipeline_run_id) as affected_runs,
MIN(quarantine_timestamp) as first_seen,
MAX(quarantine_timestamp) as last_seen,
SUM(record_size_bytes) / 1024.0 / 1024.0 as total_mb
FROM quarantine_inspection
WHERE quarantine_timestamp::date = CURRENT_DATE
AND pipeline_id = 'customer_events_pipeline'
GROUP BY failure_category, source_system
ORDER BY record_count DESC;
-- Find records that are replayable but haven't been replayed yet
SELECT
quarantine_id,
failure_category,
failure_reason,
quarantine_timestamp,
source_offset,
remediation_note
FROM quarantine_inspection
WHERE is_replayable = true
AND replay_attempted = false
AND pipeline_id = 'customer_events_pipeline'
AND quarantine_timestamp >= NOW() - INTERVAL '7 days'
ORDER BY quarantine_timestamp ASC;
Replay is where quarantine zones deliver their real value — and where most implementations go wrong. The core challenge is that replay must be idempotent: replaying a record that was already successfully processed on a previous replay attempt must not create a duplicate in your production data. This requires the same careful design as idempotent pipeline design for your original pipeline.
The replay pipeline is a first-class pipeline, not a one-off script. It needs:
class ReplayOrchestrator:
def __init__(
self,
router: QuarantineRouter,
db_conn: psycopg2.extensions.connection,
production_pipeline_fn: Callable,
s3_client=None,
max_replay_batch_size: int = 1000,
replay_rate_per_second: float = 100.0,
):
self.router = router
self.db_conn = db_conn
self.pipeline_fn = production_pipeline_fn
self.s3 = s3_client or boto3.client("s3")
self.max_batch_size = max_replay_batch_size
self.rate_limit_delay = 1.0 / replay_rate_per_second
def replay_by_criteria(
self,
pipeline_id: str,
failure_category: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
replay_run_id: Optional[str] = None,
dry_run: bool = False,
) -> dict:
"""
Replay quarantined records matching specified criteria.
dry_run=True fetches and validates records without submitting them,
useful for pre-flight checks before a large replay operation.
"""
replay_run_id = replay_run_id or str(uuid.uuid4())
results = {
"replay_run_id": replay_run_id,
"submitted": 0,
"succeeded": 0,
"re_quarantined": 0,
"skipped_not_replayable": 0,
"dry_run": dry_run,
}
candidates = self._fetch_replay_candidates(
pipeline_id=pipeline_id,
failure_category=failure_category,
date_from=date_from,
date_to=date_to,
)
batch = []
for candidate in candidates:
if not candidate["is_replayable"]:
results["skipped_not_replayable"] += 1
continue
# Fetch the original payload from S3
original_record = self._fetch_original_payload(
candidate["original_payload_ref"]
)
if original_record is None:
continue
batch.append({
"quarantine_id": candidate["quarantine_id"],
"record": original_record,
"original_metadata": candidate,
})
if len(batch) >= self.max_batch_size:
if not dry_run:
batch_results = self._execute_replay_batch(batch, replay_run_id)
results["submitted"] += batch_results["submitted"]
results["succeeded"] += batch_results["succeeded"]
results["re_quarantined"] += batch_results["re_quarantined"]
else:
results["submitted"] += len(batch)
batch = []
# Process remaining records
if batch and not dry_run:
batch_results = self._execute_replay_batch(batch, replay_run_id)
results["succeeded"] += batch_results["succeeded"]
results["re_quarantined"] += batch_results["re_quarantined"]
elif batch:
results["submitted"] += len(batch)
return results
def _execute_replay_batch(
self,
batch: List[dict],
replay_run_id: str,
) -> dict:
succeeded = 0
re_quarantined = 0
for item in batch:
# Rate limiting: don't hammer the production pipeline
time.sleep(self.rate_limit_delay)
try:
result = self.pipeline_fn(
item["record"],
pipeline_run_id=replay_run_id,
# Pass original quarantine_id so deduplication can detect
# if this record was already processed on a prior replay
idempotency_key=item["quarantine_id"],
)
if result is not None:
self._mark_replay_succeeded(item["quarantine_id"], replay_run_id)
succeeded += 1
else:
# Record got re-quarantined — update metadata to track this
self._mark_replay_re_quarantined(item["quarantine_id"], replay_run_id)
re_quarantined += 1
except Exception as e:
self._mark_replay_failed(item["quarantine_id"], replay_run_id, str(e))
re_quarantined += 1
return {"submitted": len(batch), "succeeded": succeeded, "re_quarantined": re_quarantined}
def _mark_replay_succeeded(self, quarantine_id: str, replay_run_id: str) -> None:
with self.db_conn.cursor() as cur:
cur.execute("""
UPDATE quarantine_inspection SET
replay_attempted = true,
replay_timestamp = NOW(),
remediation_note = COALESCE(remediation_note, '') || ' | Replay succeeded run=' || %s
WHERE quarantine_id = %s
""", (replay_run_id, quarantine_id))
self.db_conn.commit()
Tip
Always build a dry_run mode into your replay orchestrator. Before replaying 50,000 records against production, you want to be able to inspect the full set of candidates, validate that the failure reasons are actually resolved, and estimate the time and resource impact — without touching production. Dry runs have saved more than a few pipelines from a second failure wave.
The most dangerous failure mode for a quarantine zone isn't technical — it's organizational. Records land in quarantine, nobody has clear ownership for reviewing them, the volume grows silently, and eventually you discover that months of partial data never made it into your warehouse. This is called the graveyard effect, and it's entirely preventable with the right alerting structure.
Your quarantine zone needs its own SLA tier, integrated into your broader pipeline observability strategy. The key metrics to track and alert on:
-- Quarantine volume trend: alert if 7-day rolling quarantine rate exceeds threshold
WITH daily_counts AS (
SELECT
pipeline_id,
quarantine_timestamp::date as day,
COUNT(*) as quarantine_count
FROM quarantine_inspection
WHERE quarantine_timestamp >= NOW() - INTERVAL '30 days'
GROUP BY pipeline_id, quarantine_timestamp::date
)
SELECT
pipeline_id,
AVG(quarantine_count) as avg_daily_quarantine,
STDDEV(quarantine_count) as stddev_daily,
MAX(quarantine_count) as max_daily,
-- Alert threshold: mean + 2 standard deviations
AVG(quarantine_count) + 2 * STDDEV(quarantine_count) as alert_threshold
FROM daily_counts
GROUP BY pipeline_id;
-- Age distribution: alert on old unresolved quarantine records
SELECT
pipeline_id,
failure_category,
COUNT(*) as record_count,
MIN(quarantine_timestamp) as oldest_record,
NOW() - MIN(quarantine_timestamp) as max_age,
SUM(record_size_bytes) / 1024.0 / 1024.0 as total_mb
FROM quarantine_inspection
WHERE replay_attempted = false
AND is_replayable = true
AND quarantine_timestamp < NOW() - INTERVAL '24 hours' -- Unresolved for >24h
GROUP BY pipeline_id, failure_category
HAVING COUNT(*) > 100 -- Only alert when volume is significant
ORDER BY record_count DESC;
Establish concrete SLA tiers for different failure categories:
| Failure Category | Inspection SLA | Resolution SLA |
|---|---|---|
| Schema violation affecting >1% of records | 2 hours | 8 hours |
| Business rule violation | 24 hours | 72 hours |
| Encoding error | 8 hours | 24 hours |
| Unknown/unclassified | 4 hours | 48 hours |
These SLAs drive your alerting rules. If a schema violation category has records older than 2 hours without a remediation note attached, page someone.
Note
Quarantine SLAs should be part of your broader pipeline SLA management framework. The time records spend in quarantine counts against your overall data freshness SLAs — it's not a free pass.
When an upstream system changes its schema — adding required fields, changing types, renaming columns — you'll see a sudden spike in quarantined records all sharing the same failure reason. This is actually useful signal: it tells you something changed upstream, not that you have random data quality problems. Your quarantine system should detect this pattern and respond differently.
The schema evolution strategies you've defined for your pipeline determine what happens next: if the schema change is backward-compatible, you might auto-remediate quarantined records with default values and replay. If it's a breaking change, you might pause the pipeline entirely rather than accumulating quarantined records you can't yet process.
When you run a historical backfill over a large date range, you need quarantine behavior to be contextually appropriate. A 2-year backfill over historical data might have high quarantine rates for old records because your current schema has evolved — but those records may also be legitimately unreprocessable because the source data no longer exists in its original form.
Backfill runs should write quarantine records to a separate namespace (quarantine/backfill/ vs quarantine/production/) so that historical failures don't trigger the same SLA alerts as fresh production failures, and so that replay operations against production quarantine records don't accidentally include historical records that were never expected to process cleanly.
If your pipeline uses message queues (Kafka, SQS, RabbitMQ), quarantine zones work naturally alongside dead letter queues. The relationship is complementary: DLQs handle the message routing layer (messages that can't be delivered or acknowledged), while quarantine zones handle the application layer (messages that were delivered but failed processing). A well-designed system uses both: the DLQ catches delivery failures, the quarantine zone catches processing failures, and both feed into the same inspection and replay workflows.
In this exercise, you'll build a complete quarantine zone for a fictional e-commerce order ingestion pipeline. The pipeline consumes raw orders from a JSON file (simulating a Kafka consumer), validates and transforms them, and quarantines failures.
Setup: Create a file sample_orders.jsonl with the following records (one per line):
{"order_id": "ORD-001", "customer_id": 10042, "total_amount": 129.99, "currency": "USD", "placed_at": "2024-01-15T14:30:00Z", "items": [{"sku": "WIDGET-A", "quantity": 2, "unit_price": 64.99}]}
{"order_id": "ORD-002", "customer_id": null, "total_amount": 49.99, "currency": "USD", "placed_at": "2024-01-15T14:31:00Z", "items": []}
{"order_id": "ORD-003", "customer_id": 10043, "total_amount": -15.00, "currency": "USD", "placed_at": "2024-01-15T14:32:00Z", "items": [{"sku": "RETURN-ITEM", "quantity": 1, "unit_price": 15.00}]}
{"order_id": "ORD-004", "customer_id": 10044, "total_amount": "not_a_number", "currency": "USD", "placed_at": "2024-01-15T14:33:00Z", "items": []}
{"order_id": "ORD-005", "customer_id": 10045, "total_amount": 299.00, "currency": "BTC", "placed_at": "not_a_timestamp", "items": [{"sku": "PREMIUM-B", "quantity": 1, "unit_price": 299.00}]}
{"order_id": "ORD-006", "customer_id": 10046, "total_amount": 75.00, "currency": "EUR", "placed_at": "2024-01-15T14:35:00Z", "items": [{"sku": "BASIC-C", "quantity": 3, "unit_price": 25.00}]}
Records 2-5 all have different failure types: null customer_id, negative total (business rule), type error, and timestamp format error. Record 1 and 6 are valid.
Your tasks:
validate_order function that raises DataQualityError for each type of invalid record, with descriptive messages.QuarantineRouter from this lesson (using local file storage instead of S3 for simplicity — adapt _write_to_hot_buffer to write to ./quarantine_hot/ with the same directory structure).failure_category.inspect_quarantine function that reads all files from ./quarantine_hot/, groups them by failure_category, and prints a summary.Expected output from step 4:
Quarantine Summary:
schema_violation: 1 records
- ORD-002: DataQualityError: Missing required field: customer_id
business_rule: 1 records
- ORD-003: DataQualityError: Business rule violation: total_amount must be positive, got -15.0
type_mismatch: 1 records
- ORD-004: DataQualityError: Type mismatch: total_amount must be numeric, got 'not_a_number'
unknown: 1 records
- ORD-005: DataQualityError: Invalid timestamp format: 'not_a_timestamp'
Bonus challenge: Add a quarantine rate check: if more than 30% of records in a batch are quarantined, raise a HighQuarantineRateError that would stop the pipeline and page on-call.
Mistake 1: Quarantining transient failures Symptom: Your quarantine zone fills up with records that would have succeeded on a second attempt. Network timeouts, temporary lock failures, rate-limited API calls all show up as quarantine records. Fix: Implement the failure taxonomy described earlier. Transient exceptions should be retried with backoff before the record ever reaches quarantine routing logic. Only exhausted-retry transient failures and genuine data quality failures belong in quarantine.
Mistake 2: Not capturing the original raw payload
Symptom: You try to replay a record, but the replay fails with a different error because the partially-transformed version you stored is missing fields that your current pipeline logic now requires.
Fix: Always store the original_payload as the first capture, before any parsing or transformation. Store it as raw bytes if possible. Your parsed partial is useful supplementary data, not the replay source.
Mistake 3: Using quarantine to mask schema evolution events Symptom: Upstream changes a field name. Suddenly 100% of records are quarantined. You fix the pipeline code, replay everything, and it works. But you never investigated why the upstream changed — and they're about to change something else. Fix: When quarantine rate spikes above a threshold (e.g., >10% of a batch), automatically classify this as a potential systemic event and trigger a different alert flow. These situations need schema investigation, not just replay.
Mistake 4: Replay without idempotency
Symptom: You replay 10,000 records and discover that 2,000 of them were actually successfully processed on the second try (before you stopped the original pipeline), so now they're duplicated in your data warehouse.
Fix: Every record entering replay must have a unique idempotency key (the quarantine_id works well). Your production pipeline's write path must check for and reject duplicate keys. See idempotent pipeline design for the write-path implementation.
Mistake 5: No quarantine volume monitoring Symptom: Records have been quietly accumulating in quarantine for three weeks. Nobody noticed because the production pipeline kept running successfully. When someone finally looks, there are 2 million unprocessed records. Fix: Quarantine volume and age must be first-class metrics in your pipeline observability setup. Alert on both volume (absolute count exceeding thresholds) and age (records unresolved beyond SLA windows). Treat these alerts with the same urgency as production pipeline failures.
Mistake 6: Quarantine writes failing silently Symptom: Your quarantine write fails (S3 is momentarily unavailable, permission error on the bucket, etc.), the exception is swallowed, and you think you're quarantining records but actually dropping them. Fix: Quarantine write failures must be treated as critical errors. The fallback should be a local file write or a secondary durable destination — never a silent discard. If you can't quarantine a record, you must either keep it in the source system or stop the pipeline. Losing a record in this way is worse than stopping the pipeline.
Warning
Never let quarantine write failures fail silently. If your quarantine mechanism is itself unreliable, you lose the fundamental guarantee that "no record is lost" — which is the entire value proposition of the quarantine pattern. Build a fallback write path and alert aggressively on quarantine write errors.
Quarantine zones are infrastructure for trust. They let you make a promise to your stakeholders — "no data record will be silently dropped" — and actually keep it. When implemented well, they transform pipeline failures from opaque production incidents into structured, auditable events with clear ownership and resolution workflows.
The key design decisions you've now worked through:
Where to go from here: If you're building quarantine for streaming pipelines, dig into how checkpointing and state management interacts with per-record quarantine routing — particularly around maintaining consumer offsets correctly when individual records within a batch are quarantined. If your pipeline handles late-arriving data, you'll want to reconcile your quarantine replay windows with your grace window logic. And if you're operating at high volume, revisiting throughput estimation with quarantine overhead factored in is essential — the extra writes and metadata capture do add measurable latency.