🐍 Python for Data Engineers · Chapter 10
Python: validating events at the gate
Every pipeline has a front door. Validation is the lock — and where the rejected records go matters more than the check itself.
Study this lesson free in the app →
The basics
Validation is the check that runs on every incoming record before your pipeline trusts it: are the required fields there, are the types right, does the value make sense?
The problem it solves: producers break. A mobile release sends event_time in a new format, a retry delivers the same ride twice, a bug sends fare: null. If nothing stops these at the door, they flow into tables and dashboards, and someone prices surge in Bengaluru off a number that was never true.
The mental model: a bouncer with a notebook. Every record either walks in, or is turned away and written down with the reason. The notebook — the dead letter queue — is what lets you find the broken producer and replay the records once it is fixed.
Words you must know 5
- Validation
- rule checks on a single record (required, type, range, format)
- Rejection reason
- a short code like
missing_ride_id, not aFalse - Dead letter queue (DLQ)
- where rejected records wait, with reason and offset, for replay
- Deduplication
- dropping repeat deliveries of the same
event_id - Data contract
- the producer's promise about schema, enforced by a schema registry or Pydantic model
Explained
Bad data does not announce itself. It arrives as fare: null, event_time: "31/07/2026", a ride_completed with no ride_id, and a duplicate from a retry.
If you do not stop it at the door, it reaches a dashboard, and someone makes a pricing decision on it.
The nightclub bouncer
Three bouncers, three different clubs.
Bouncer A turns away anyone suspicious and forgets them. The club is clean, but nobody can explain why Tuesday's crowd was small.
Bouncer B lets everyone in to avoid confrontation. Chaos inside, discovered at 3 AM.
Bouncer C turns people away and writes down who, when, and why. In the morning there is a list — and it turns out 400 people were rejected because one door scanner had the wrong date format.
Be Bouncer C. The rejection log is worth more than the rejection.
The shape to write
from dataclasses import dataclass
from datetime import datetime, timezone
REQUIRED = ("event_id", "ride_id", "event_type", "event_time")
@dataclass
class Rejection:
event: dict
reason: str
def validate(event: dict) -> Rejection | None:
"""Return None if the event is good, else why it was rejected."""
for field in REQUIRED:
if not event.get(field):
return Rejection(event, f"missing_{field}")
try:
ts = datetime.fromisoformat(event["event_time"].replace("Z", "+00:00"))
except (ValueError, AttributeError):
return Rejection(event, "bad_event_time")
if ts > datetime.now(timezone.utc):
return Rejection(event, "future_timestamp") # client clock skew
if event["event_type"] == "ride_completed":
fare = event.get("fare")
if not isinstance(fare, (int, float)) or fare <= 0:
return Rejection(event, "invalid_fare")
return NoneNote what it returns: not True/False, but the reason. A boolean throws away the only thing you will want at 3 AM.
Validate, dedupe, aggregate — in that order
def total_completed_fare(events: list[dict]) -> tuple[float, list[Rejection]]:
total, seen, rejected = 0.0, set(), []
for e in events:
if (r := validate(e)) is not None:
rejected.append(r) # → dead letter queue
continue
if e["event_id"] in seen:
continue # retry duplicate, silently fine
seen.add(e["event_id"])
if e["event_type"] == "ride_completed":
total += e["fare"]
return round(total, 2), rejectedThe order is not cosmetic. Dedupe before you aggregate, or one Kafka retry inflates revenue. Validate before you dedupe, or a malformed event without an event_id poisons the seen set.
Where this goes in production
Hand-rolled checks are fine to learn on; production usually reaches for declarative validation:
- Pydantic — typed models with coercion and clear errors, at the service boundary
- JSON Schema / Avro / Protobuf + a schema registry — the producer's contract is enforced before the event is even accepted
- Great Expectations / dbt tests / Soda — table-level assertions after landing: not-null, uniqueness, accepted values, row-count and freshness thresholds
- Delta/Iceberg constraints — reject bad rows at write time
And the rule underneath all of them: validate as early as the data enters your control, assert again where it is consumed. Cheap at the gate, expensive in the mart.
One bad record must not stop the line
The single most important design decision in a consumer: what happens on a record you cannot process.
- Crash — the partition stalls, lag climbs, the on-call phone rings. Correct only if the failure is systemic (the DB is down)
- Skip silently — data disappears with no evidence. Never acceptable
- Dead letter queue — park the record with its reason, timestamp and offset; keep consuming; alert on DLQ rate
The DLQ is not a graveyard. It needs an owner, a dashboard, and a replay path — fix the parser, re-drive the DLQ through the same idempotent consumer, and the data lands correctly. Without replay you have merely built a nicer way to lose data.
Validate → dedupe → aggregate. In that order, every time.
Reject with a reason, not a boolean.
A DLQ without a replay path is just a slower delete.
Gotchas
if not event.get("fare")rejects a legitimate0.0— useis Nonewhen zero is validfloatfor money accumulates rounding error; useDecimalfor anything that reconciles against a bank- Naive
datetimeobjects (no timezone) compared against aware ones raiseTypeError— always parse to UTC-aware - A bare
except:swallowsKeyboardInterruptand hides real bugs; catch the specific exception - Mutating the input dict during validation — the raw event should reach the DLQ exactly as it arrived
Words to know
| Dead letter queue | A holding area for records that failed processing, kept with their failure reason for replay. |
|---|---|
| Schema registry | A central store of event schemas that enforces compatibility between producers and consumers. |
| Pydantic | Python library for declarative, typed data validation with coercion and structured errors. |
| Data contract | The enforced agreement about a stream's schema and semantics between producer and consumers. |
| Clock skew | Disagreement between clocks on different machines — why client timestamps can arrive from the future. |
| Walrus operator | Python's := which assigns inside an expression, as in if (r := validate(e)) is not None. |
In two minutes
- Validation is the front door: every record is checked for required fields, types, formats and sane ranges before it is trusted.
- A validator returns a reason (
missing_ride_id,bad_event_time), never a bare boolean — the reason is what you route, count and debug on. - Process in a fixed order: validate → dedupe → aggregate. Validate first so junk never enters the dedup set; dedupe before summing so retries cannot inflate revenue.
- A bad record must not stop the line: park it in a dead letter queue with its reason and offset, keep consuming, alert on the DLQ rate.
- A DLQ needs an owner and a replay path through the same idempotent consumer, or it is just a slower delete.
- Classic Python traps:
if not farerejects a valid 0.0, naive vs aware datetimes raise TypeError, float drifts for money. - In production use declarative tools — Pydantic or a schema registry at the boundary, dbt/Great Expectations tests after landing.
Test yourself
1. What is the correct order of operations?
- Aggregate → validate → dedupe
- Validate → dedupe → aggregate
- Dedupe → aggregate → validate
- Order does not matter
Show the answer
Validate → dedupe → aggregate — Validate first so malformed records never enter the dedup set; dedupe before aggregating so retries cannot inflate totals.
2. Why return a reason instead of True/False from a validator?
- It runs faster
- The reason is what you need to debug and to route the record
- Booleans cannot be serialised
- It is required by Pydantic
Show the answer
The reason is what you need to debug and to route the record — 'Invalid' tells you nothing at 3 AM. 'missing_ride_id ×400 since the mobile release' tells you exactly what to fix.
3. A single unparseable record arrives. What should a production consumer do?
- Crash so someone notices
- Skip it silently
- Send it to a DLQ with its reason and keep consuming
- Retry it forever
Show the answer
Send it to a DLQ with its reason and keep consuming — Crashing stalls the partition for everyone; silent skipping loses data invisibly. The DLQ preserves the record and the evidence — and must have a replay path.
4. Which bug does if not event.get('fare') introduce?
- It rejects a legitimate fare of 0.0
- It throws on missing keys
- It accepts negative fares
- It is slower than a direct index
Show the answer
It rejects a legitimate fare of 0.0 — 0.0 is falsy in Python. When zero is a valid value, test is None explicitly. This exact bug appears in real pipelines constantly.
5. An event arrives with event_time: "2026-07-31T10:00:00" (no Z, no offset). The validator parses it with fromisoformat inside try/except ValueError, then checks ts > datetime.now(timezone.utc) outside the try. What happens?
- It is accepted as valid
- It is rejected as bad_event_time
- A TypeError escapes and crashes the loop
- Python assumes UTC and compares correctly
Show the answer
A TypeError escapes and crashes the loop — fromisoformat happily returns a naive datetime. Comparing naive with aware raises TypeError: can't compare offset-naive and offset-aware datetimes — and that line sits outside the try, so nothing catches it. Python never assumes UTC; you must attach a timezone or reject.
6. The producer starts sending fare: "280.50" (a string). With the check isinstance(fare, (int, float)) and fare > 0, what happens to those events?
- Rejected as invalid_fare
- Accepted and summed as 280.5
- They crash the sum with TypeError
- Silently coerced to 0
Show the answer
Rejected as invalid_fare — A str is not int or float, so the check fails and the event goes to the DLQ as invalid_fare. That is the desirable outcome: the DLQ spike tells you the producer changed type. Silent coercion would hide the contract break.
There are 9 questions, 3 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.
Start this lesson →