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.
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:
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.
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.
Why is bad data silently accepted by a @dataclass often worse than an immediate crash?
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.