The validation gap

Recap: the gap, briefly

The previous section ended on this, and it’s worth restating as the starting point here: a @dataclass reads type hints to know which fields exist — it never checks that what’s actually passed in matches them.

Try it — edit and run

This section makes the actual cost of that gap concrete, then shows the manual fix — before the next section replaces the manual fix with something better.

Why silent bad data is worse than it looks

The real danger isn’t that broken above prints strangely — it’s that nothing stops broken from being passed around and used elsewhere, somewhere the bad data finally causes a crash that has nothing obviously to do with where it actually went wrong:

Try it — edit and run

This is the same shape of problem covered all the way back in the Python setup lesson: the crash happens where execution actually reaches the bad value, not where the mistake was actually made. There, it was two lines in the same file; here, it can easily be a config object created far away from — and long before — wherever it finally breaks something. The farther the gap between creation and crash, the harder the bug is to trace back.

The manual fix: validating in __post_init__

@dataclass gives you a hook specifically for logic that should run right after the generated __init__ finishes: a method named __post_init__, called automatically with no arguments beyond self once every field has already been assigned.

Try it — edit and run

raise is new syntax here: it’s how you trigger an exception yourself, rather than one occurring naturally from something like division by zero. raise ValueError("message") immediately stops execution at that line and produces a traceback, exactly like any other uncaught exception — the difference is you’re deciding exactly when and why it happens, based on whatever condition you check. This is the direct inverse of try/except from earlier in the course: except catches an exception someone else raised; raise is how you create one in the first place.

This version fails immediately, at construction time, at the exact line where the actual mistake happened — instead of failing later, somewhere unrelated, the way the previous example did.

Why the manual version doesn't scale

__post_init__ genuinely works — but look at what it cost for a class with only two fields worth checking. Every field needs its own isinstance check, its own error message, written by hand:

@dataclass
class AgentConfig:
    name: str
    model: str
    temperature: float = 0.7
    max_tokens: int = 1000
    enabled: bool = True

    def __post_init__(self):
        if not isinstance(self.name, str):
            raise ValueError(f"name must be a str, got {type(self.name).__name__}")
        if not isinstance(self.model, str):
            raise ValueError(f"model must be a str, got {type(self.model).__name__}")
        if not isinstance(self.temperature, (int, float)):
            raise ValueError(f"temperature must be a number, got {type(self.temperature).__name__}")
        if not isinstance(self.max_tokens, int):
            raise ValueError(f"max_tokens must be an int, got {type(self.max_tokens).__name__}")
        if not isinstance(self.enabled, bool):
            raise ValueError(f"enabled must be a bool, got {type(self.enabled).__name__}")

(not run live — illustrating how the check count scales with field count, not a new behavior)

Five fields, five nearly-identical checks — and this is still only checking type, not anything more specific (a temperature of -5.0 is a float, so it passes every check above, despite being nonsensical for an actual sampling temperature). Every new field means writing another check by hand, and every check is exactly the kind of repetitive, easy-to-typo, easy-to-forget code this course has been steering away from since comprehensions replaced manual loop-and-append and @dataclass itself replaced manually-written __init__/__repr__/__eq__.

The type hints (name: str, temperature: float) are already sitting right there in the class, fully describing what each field should be — __post_init__ just isn’t reading them; you’re re-stating the same information a second time, by hand, as a separate check. That redundancy is exactly the opening the next section closes: Pydantic reads those same type hints directly and turns them into this validation automatically, with no __post_init__ written by hand at all.

Check your understanding
1/5

Why is bad data silently accepted by a @dataclass often worse than an immediate crash?

Exercise · Graded

Implement __post_init__ on ToolCall with three checks: tool_name must be a non-empty str, timeout must be a number (int or float) greater than 0, and arguments must be a dict — raising ValueError the moment any one of them fails.