◐ ai-teacher

🐍 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 a False
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 None

Note 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), rejected

The 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:

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.

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

Words to know

Dead letter queueA holding area for records that failed processing, kept with their failure reason for replay.
Schema registryA central store of event schemas that enforces compatibility between producers and consumers.
PydanticPython library for declarative, typed data validation with coercion and structured errors.
Data contractThe enforced agreement about a stream's schema and semantics between producer and consumers.
Clock skewDisagreement between clocks on different machines — why client timestamps can arrive from the future.
Walrus operatorPython's := which assigns inside an expression, as in if (r := validate(e)) is not None.

In two minutes

Test yourself

1. What is the correct order of operations?

  1. Aggregate → validate → dedupe
  2. Validate → dedupe → aggregate
  3. Dedupe → aggregate → validate
  4. 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?

  1. It runs faster
  2. The reason is what you need to debug and to route the record
  3. Booleans cannot be serialised
  4. 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?

  1. Crash so someone notices
  2. Skip it silently
  3. Send it to a DLQ with its reason and keep consuming
  4. 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?

  1. It rejects a legitimate fare of 0.0
  2. It throws on missing keys
  3. It accepts negative fares
  4. 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?

  1. It is accepted as valid
  2. It is rejected as bad_event_time
  3. A TypeError escapes and crashes the loop
  4. 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?

  1. Rejected as invalid_fare
  2. Accepted and summed as 280.5
  3. They crash the sum with TypeError
  4. 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.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →