Validating Attendee Data with Pydantic Before Ingestion: Fixing Silent Coercion & Poisoned Batches
Symptom Statement Link to this section
Badge fulfillment queues stall mid-batch with UnicodeDecodeError or a KeyError on a field the renderer assumed was always present. Reconciliation dashboards report persistent $0.00 discrepancies despite confirmed gateway authorizations, and async worker pools exhaust their retry budgets and crash with TypeError: unsupported operand type(s) for +: 'NoneType' and 'str'. These are not isolated bugs — they are cascading symptoms of malformed records slipping past the ingestion boundary, where downstream systems assume canonical shapes and silently coerce, drift, or die when the shape is wrong. This page is the attendee-payload contract documented under schema validation pipelines, the deterministic gate stage of the Registration Ingestion & Payment Reconciliation pipeline — it covers the exact Pydantic v2 model and fast-fail wrapper that turn untrusted external payloads into canonical records before they reach a broker, a ledger, or a badge template.
Root Cause Analysis Link to this section
Three concrete faults produce this class of failure. Each sits at a different boundary — the producer contract, the Python type system, and the enqueue path — and each survives a naive “we already parse the JSON” defense.
- Divergent producer contracts. Registration data arrives from two upstreams with incompatible guarantees. Deterministic pull from form API polling returns paginated arrays with inconsistent casing (
ticketPricevsticket_price), while event-driven payment webhook handling ships payloads whose schema version shifts under you. Neither stream promises a stable shape, and a renamed key becomes a missing field the moment a provider ships a release. - Silent type coercion. Python’s dynamic typing masks bad values until execution reaches the template engine or the accounting job. A
ticket_priceof"29.95"sails through as a string and detonates decimal math downstream; adietary_restrictionsarray carrying anullelement corrupts CSV export; trailing whitespace incompany_namebreaks the badge alignment pass. The value looks fine at ingest and is fatal three stages later. - No fast-fail gate before enqueue. With no hard contract at the trust boundary, both streams inject heterogeneous data straight into the message broker. One malformed record then poisons an entire chunk — the worker draining it dies, the chunk is redelivered, and the same bad record kills the next worker, amplifying a single dirty payload into a stalled queue.
Symptom-to-Resolution Matrix Link to this section
Divergent Producer Contracts Link to this section
Symptoms
- The renderer raises
KeyError: 'ticket_price'on payloads from one provider but not another. - A working ingest path breaks after an upstream provider ships a release, with no code change on your side.
- Fields arrive camelCase from polling and snake_case from webhooks against the same model.
Root cause. No canonical field spec exists at the edge, so every producer’s casing and versioning quirks flow straight through.
Fix steps
- Pin one canonical snake_case schema and accept producer aliases explicitly with
populate_by_name=TrueplusField(alias=...), soticketPriceandticket_priceboth map to one field. - Reject anything you did not declare with
extra="forbid", converting silent schema drift into a loud, categorizable error at ingest. - Disable implicit coercion with
strict=Trueso a value that is the wrong type fails here rather than being quietly cast.
from pydantic import Field
# camelCase from polling and snake_case from webhooks both resolve here
ticket_price: Decimal = Field(alias="ticketPrice", ge=0, decimal_places=2)
Silent Type Coercion Link to this section
Symptoms
- Reconciliation shows
$0.00gaps even though the gateway confirms the charge. - CSV exports contain empty cells or a literal
nullwhere a diet tag should be. - Badge text is misaligned by a leading space no one can see in the data.
Root cause. Values enter as the wrong type or with junk whitespace, and dynamic typing defers the failure until arithmetic or rendering forces it.
Fix steps
- Sanitize strings before typing with a
BeforeValidatorattached throughAnnotated, stripping whitespace and null bytes at the door. - Parse money into
Decimal, neverfloat— strip currency symbols in amode="before"validator so"$29.95"and29.95both land asDecimal("29.95"). - Filter
Noneout of list fields and deduplicate, so anullindietary_restrictionscan never reach the CSV writer.
def sanitize_string(v: str | None) -> str | None:
if v is None:
return None
return v.strip().replace("\x00", "") # kill whitespace + null bytes at ingest
No Fast-Fail Gate Before Enqueue Link to this section
Symptoms
- One bad record redelivers forever and stalls the whole chunk.
- Workers crash-loop on
TypeErrorwhile draining a batch that was 99% clean. - The dead-letter queue is empty even though records are clearly being lost.
Root cause. Validation happens (if at all) inside the worker after enqueue, so a poison payload is already in the broker before anything checks it.
Fix steps
- Validate with
model_validate()before the record touches the broker — the gate returns a canonical dict or nothing. - Categorize each
ValidationError(syntax, business rule, schema drift) so recoverable and fatal failures route differently instead of all retrying blindly. - Route failures to the dead-letter queue with a deterministic payload fingerprint so the exact upstream is traceable during triage.
# gate at the edge: canonical dict out, or None (never a half-valid model)
clean = validate_and_route(payload)
if clean is None:
return # already dead-lettered; never enqueued
Minimal Working Implementation Link to this section
A single self-contained path: the canonical model with strict typing and business rules, a categorizing gate that emits a canonical dict or dead-letters the record, and a bounded async fan-out that keeps the gate off the broker’s hot path. The records this produces feed async batch processing; the reconciliation and windowing beyond this boundary are owned there, not here.
import asyncio
import hashlib
import logging
import re
from decimal import Decimal
from datetime import date
from enum import Enum
from typing import Annotated, Optional, Literal
from pydantic import (
BaseModel, Field, field_validator, model_validator,
BeforeValidator, ValidationError, ConfigDict,
)
logger = logging.getLogger("ingestion.validation")
EMAIL_RE = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
def sanitize_string(v: str | None) -> str | None:
if v is None:
return None
return v.strip().replace("\x00", "")
# BeforeValidator MUST be carried via Annotated in Pydantic v2 — not class-level assignment
SanitizedStr = Annotated[str, BeforeValidator(sanitize_string)]
OptionalSanitizedStr = Annotated[Optional[str], BeforeValidator(sanitize_string)]
class AttendeeIngestionSchema(BaseModel):
model_config = ConfigDict(
strict=True, # no silent coercion
extra="forbid", # unknown field == schema drift, caught here
populate_by_name=True, # accept both canonical name and producer alias
validate_assignment=True,
)
attendee_id: str = Field(min_length=1, max_length=64)
email: str
first_name: SanitizedStr = Field(min_length=1, max_length=50)
last_name: SanitizedStr = Field(min_length=1, max_length=50)
ticket_type: Literal["standard", "vip", "press", "speaker"]
ticket_price: Decimal = Field(alias="ticketPrice", ge=Decimal("0.00"), decimal_places=2)
dietary_restrictions: list[str] = Field(default_factory=list)
company_name: OptionalSanitizedStr = Field(default=None, max_length=100)
registration_date: date
@field_validator("email")
@classmethod
def validate_email_format(cls, v: str) -> str:
if not EMAIL_RE.match(v):
raise ValueError("Invalid email format")
return v.lower()
@field_validator("ticket_price", mode="before")
@classmethod
def coerce_price(cls, v):
if isinstance(v, str):
cleaned = re.sub(r"[^\d.]", "", v) # strip currency symbols/whitespace
if not cleaned:
raise ValueError("Price string contains no numeric value")
return Decimal(cleaned)
if isinstance(v, (int, float)):
return Decimal(str(v)) # str() first — never Decimal(float) directly
raise ValueError("Unsupported price type")
@field_validator("dietary_restrictions")
@classmethod
def filter_null_restrictions(cls, v: list) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for item in v:
if item is None:
continue
s = str(item).strip()
if s and s not in seen:
seen.add(s)
out.append(s)
return out
@model_validator(mode="after")
def validate_business_rules(self) -> "AttendeeIngestionSchema":
if self.ticket_type == "speaker" and self.ticket_price > Decimal("0.00"):
raise ValueError("Speaker tickets must be complimentary (price=0.00)")
if self.ticket_type == "vip" and not self.company_name:
raise ValueError("VIP attendees require a valid company_name")
return self
class ErrorCategory(str, Enum):
SYNTAX_ERROR = "syntax_error" # recoverable formatting fault
BUSINESS_RULE_VIOLATION = "business_rule_violation"
UNKNOWN_FIELD = "unknown_field" # schema drift — page on it
def categorize_error(err: ValidationError) -> ErrorCategory:
for e in err.errors():
if e["type"] == "extra_forbidden":
return ErrorCategory.UNKNOWN_FIELD
if e["type"] in ("value_error", "assertion_error"):
return ErrorCategory.BUSINESS_RULE_VIOLATION
return ErrorCategory.SYNTAX_ERROR
def validate_and_route(payload: dict) -> dict | None:
"""Canonical dict out, or None after dead-lettering. Never a half-valid model."""
try:
return AttendeeIngestionSchema.model_validate(payload).model_dump(mode="json")
except ValidationError as e:
category = categorize_error(e)
detail = {
"category": category.value,
"fields": [err["loc"] for err in e.errors()],
"payload_fp": hashlib.sha256(
str(sorted(payload.items())).encode()
).hexdigest()[:16], # deterministic fingerprint for triage
}
if category == ErrorCategory.UNKNOWN_FIELD:
logger.critical("schema_drift %s", detail) # provider shipped a new field
elif category == ErrorCategory.BUSINESS_RULE_VIOLATION:
logger.error("business_rule %s", detail)
else:
logger.warning("syntax_error %s", detail)
return None # dead-letter; never enqueued
class IngestionPipeline:
def __init__(self, max_concurrent: int = 50):
self.semaphore = asyncio.Semaphore(max_concurrent)
async def process_batch(self, payloads: list[dict]) -> list[dict]:
async def _one(p: dict) -> dict | None:
async with self.semaphore:
# validation is CPU-bound — keep it off the event loop
return await asyncio.to_thread(validate_and_route, p)
results = await asyncio.gather(*[_one(p) for p in payloads])
return [r for r in results if r is not None] # drop the dead-lettered
if __name__ == "__main__":
# Verification: dirty payload is rejected, clean payload survives with canonical types.
dirty = {"attendee_id": "a1", "email": "[email protected]", "first_name": " Ada ",
"last_name": "Byte", "ticket_type": "vip", "ticketPrice": "$0.00",
"dietary_restrictions": ["vegan", None, "vegan"],
"registration_date": "2026-07-02"} # VIP with no company_name -> rejected
assert validate_and_route(dirty) is None
clean = {**dirty, "company_name": " Acme Corp ", "ticketPrice": "$120.00"}
out = validate_and_route(clean)
assert out["ticket_price"] == "120.00" # Decimal, not float
assert out["email"] == "[email protected]" # lowercased
assert out["dietary_restrictions"] == ["vegan"] # null dropped, deduped
assert out["company_name"] == "Acme Corp" # whitespace stripped
print("gate OK:", out)
Memory & Performance Constraints Link to this section
| Component | Constraint | Mitigation |
|---|---|---|
| Validation latency | strict=True skips coercion but adds per-field checks |
Accept it at the edge; correctness outweighs raw throughput here |
| Model construction | AttendeeIngestionSchema(**payload) pays __init__ overhead |
Use model_validate(payload) — it hits the compiled core directly |
| Regex cost | Per-record re.compile inside a validator is wasted work |
Pre-compile patterns (EMAIL_RE) at module load |
| CPU vs event loop | Validation is CPU-bound and blocks async I/O | Run through asyncio.to_thread, bound by an asyncio.Semaphore |
| Serialization weight | Pydantic model objects are heavier than dicts in the broker | Emit model_dump(mode="json") before enqueue, never the model |
| Worker RSS on bursts | Large batches hold every payload resident | Chunk at batch_size≈200; cap max_concurrent at vCPU count |
Incident Triage & Rollback Link to this section
Target under 15 minutes to a clean fallback. Contain the poisoned stream first; never restart workers before the drift is patched, or the same record re-poisons the fresh process.
- Find the poison. Grep worker logs for the category and fingerprint:
grep 'schema_drift' app.log | tail -20— thepayload_fppins the exact upstream provider. - Isolate the stream. Disable the failing ingestion route via feature flag (
export VALIDATION_ROUTE_DISABLED=webhooks) so clean streams keep flowing while you patch. - Inspect the dead-letter queue.
redis-cli LLEN ingestion.dlqto size the blast radius, then sample one:redis-cli LINDEX ingestion.dlq 0. - Replay cleaned payloads. Pull from the DLQ and re-run
validate_and_routeoffline; patch the fixable formatting errors and re-enqueue only records that now return a dict.
Rollback to audit mode. If strict validation rejects legitimate payloads because of an unforeseen drift, do not disable validation outright — switch it to non-blocking: export VALIDATION_MODE=audit logs every ValidationError but lets the payload pass, so you capture the drift pattern without dropping records. Patch AttendeeIngestionSchema with the missing Optional field or alias, then re-run the gate against 10k historical payloads locally before redeploying.
Rollback validation. Redeploy with VALIDATION_MODE=enforce, watch the category counters for 15 minutes, and confirm the syntax_error rate holds below 0.5% and schema_drift returns to zero before restoring full ingestion load.
Frequently Asked Questions Link to this section
Why strict=True if it rejects payloads that “would have worked” after coercion?
Because the coercion is exactly the failure mode. A ticket_price silently cast from "29.95" looks fine at ingest and breaks decimal reconciliation three stages later, with no trace back to this boundary. Strict mode moves that failure to the one place that can categorize it and dead-letter the record — the edge — instead of a worker crash mid-batch. Where a producer legitimately sends the wrong type, you handle it explicitly in a mode="before" validator, not implicitly.
Why parse money into Decimal instead of float?
float cannot represent most base-10 fractions exactly, so 0.1 + 0.2 is not 0.3 and a summed ledger drifts by cents that never reconcile. Decimal is exact for the two-place amounts tickets use. Note the code does Decimal(str(v)), never Decimal(float_value) directly — constructing a Decimal from a float inherits the float’s binary rounding error.
Should BeforeValidator be assigned as a class attribute like in some v1 examples?
No. In Pydantic v2 a BeforeValidator must be carried on the field’s type via Annotated (Annotated[str, BeforeValidator(sanitize_string)]). A class-level assignment silently does nothing, so the sanitizer never runs and whitespace or null bytes reach your downstream unstripped.
Related Link to this section
- Schema Validation Pipelines — the parent stage that defines the versioned contract and dead-letter taxonomy this model implements.
- Using Celery for Async Registration Batch Processing — the worker layer that drains the canonical records this gate emits, and revalidates them at its own boundary.
- Handling Stripe Payment Webhooks for Ticket Purchases — one of the divergent producers whose shifting payload versions this schema pins down.
- Polling Eventbrite Web APIs Without Rate Limiting — the other producer, whose camelCase pagination this model normalizes via field aliases.