Validate Dataclasses with __post_init__
Reject reversed range bounds and calculate a derived span immediately after a generated dataclass initializer runs.
Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons
Video lesson: Validate Dataclasses with __post_init__
The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube
Jump to a chapter
Read the video transcript
Select a timestamp to watch that moment on YouTube.
- 00:00
The constructor accepted a lower bound of eight and an upper bound of two. For this application, that is impossible. The earlier dataclass lesson showed how annotated fields give us an initializer, a useful representation, equality, and safer defaults for mutable values. None of those generated conveniences decides whether one field is consistent with another. The annotations say the two values are intended to be integers, but even that type intention is not enforced automatically at runtime. If I passed the reversed range through several functions, the eventual failure might appear far from this construction line.
- 00:41
A better place to reject it is immediately after the generated initializer assigns both fields. We will use the dataclass `__post_init__` hook for that check. Then we will derive a `span` value from the bounds so callers cannot accidentally pass a contradictory third number. The rule is simple: equal bounds are valid and have span zero; a low value greater than high is invalid. Watch the code run at every stage. One successful sample does not prove a class enforces its invariant, so our final tests will include an equal-bound case and the reversed case that this first run accepted. Notice the problem is about a relationship, not a single field's type.
- 01:25
A generic `@dataclass` decorator cannot infer that relationship from the words `low` and `high`; the developer has to express it in code. We did not write an `__init__` method and we did not call `__post_init__` explicitly. The dataclass generated the initializer, assigned `left` and `right`, and then called the hook. The output shows that both values are available in the hook. This is a suitable place to compare them. It is easy to misread this mechanism as something that runs whenever an object changes. It does not. If someone later writes to a field on a mutable dataclass, the post-init hook is not automatically called again.
- 02:06
That is important when a class stores derived values. Our classroom Range will be constructed and read, and we will say clearly that later direct mutation could make a cached span stale. An application requiring permanent validity could expose controlled methods, use a different immutable design, or avoid storing a redundant span. The hook solves the initial-construction rule. It also receives `InitVar` values in more specialized dataclass designs, but we do not need them here. The data relationship is visible using ordinary self.low and self.high references. Pause and predict what would print if Pair receives values in a different order.
- 02:49
Then consider how we can turn the same visibility into an error message at the earliest useful point. We want the caller to see exactly which rule failed, not to obtain a silently repaired object whose bounds no longer match the requested input. Construction now fails at the point where the invalid relationship appears. The try and except block is only here so this demonstration can show the exception text and continue to the valid case. A calling program could convert that ValueError into a form message or reject an invalid configuration file. We do not use an `assert` for this runtime rule.
- 03:29
Python can remove assertion statements under optimization, which would let the invalid object appear again. We also do not swap the two values automatically. Swapping could hide a typo, and the caller would receive a different range from the one it supplied. The intended contract is to reject reversed bounds. The valid case constructs normally. The automatically generated `repr` shows its initial values, but the representation is not itself a validation tool. If your application receives text from an external source, you would also decide how to parse and verify its types before making the Range. Here we focus on the ordering rule and use clean integer inputs to keep the cause
- 04:11
visible. Before adding a derived value, run a case with equal bounds and make a prediction: because the condition is strictly greater than, equality should pass. The next step will calculate zero for that range. This is how a small business rule becomes an explicit invariant rather than an assumption buried in later calculations. The span is calculated only after the bounds have passed validation. We declare it as a dataclass field because it is part of the object's stored state and useful representation, but `init=False` removes it from the generated constructor parameters.
- 04:52
The caller cannot write `Range(2, 8, 999)` and make the span contradict its bounds. Printing the signature gives us a visible check of that interface. The exact displayed formatting of the signature may vary by Python version, so our automated task checks examine its parameters rather than compare one formatted line of text. The important result is that a caller supplies low and high, while `__post_init__` assigns span. This is not automatic arithmetic performed by dataclasses. It is our code, executed at a documented lifecycle point. Also, a derived field is not necessarily worth storing in every design.
- 05:34
If Range objects will change frequently, a property that computes `high - low` on demand might avoid stale state. This lesson demonstrates post-init because the application needs an initial validation hook and a one-time derived value. For a frozen dataclass, ordinary assignment inside the hook would require a different technique; do not copy this mutable example into a frozen class without adapting it. Those boundaries keep the short example honest. The valid range produces six, the equal- bound range produces zero, and reversed bounds raise the exact error we promised. The assertions here verify our demonstration; they are not a replacement
- 06:16
for the ValueError inside the class. That distinction is easy to miss because both statements can stop a program. The class's conditional check enforces the runtime rule, while the test assertions report whether our examples behave as expected during development. On the lesson page, start from the broken version that calculates a negative span. Add the guard, press Run, and then select Check task. The task checker will try more than the one visible prepared call. After that, answer the three quiz questions on lifecycle, `field(init=False)`, and why runtime validation uses an exception instead of an assertion.
- 06:58
This is the practical value of small, explicit checks: a Range should either begin in a consistent state or fail immediately with a clear message. When you design another dataclass, ask whether a field is caller input or a value derived from other fields. Ask whether relationships need validation. Ask whether the object may be mutated later. Dataclasses reduce boilerplate, but they do not replace these decisions. The next lesson on structured concurrency will make a similar point in a different domain: convenient constructs help only when their lifecycle and failure boundaries are understood.
Understand the concept
The earlier dataclass lesson covered generated initialization, equality, repr, safe defaults, and asdict. A generated initializer assigns fields, but annotations alone do not enforce relationships between them. If a Range has low and high values, Range(10, 2) is invalid for our application even though both are integers. Dataclasses call __post_init__ after the generated __init__ assigns the fields. That hook can check the relationship and raise a clear ValueError.
A derived value should have one source of truth. Instead of asking the caller to pass span separately, declare span with field(init=False) and calculate it as high - low in __post_init__. The caller supplies only low and high. Once validation succeeds, every new Range starts with a consistent span. This is ordinary mutable-dataclass behavior: later direct assignment to low would not automatically recalculate span. Make that limitation explicit or choose a different design if mutation is expected.
Raising ValueError communicates that the supplied bounds violate this class's value rule. Do not use assert for runtime input validation: optimized Python may remove assertions. Do not silently swap low and high, because doing so hides a caller error and changes the meaning of a range. Also avoid calling __post_init__ manually after each construction. The generated initializer already invokes it at the right time.
The task tests valid bounds, equal bounds, reversed bounds, and derived span. It intentionally leaves type enforcement out of scope: a type hint is not a runtime check. If an external API could send strings or booleans, validate those separately at the boundary before constructing the object. For immutable dataclasses, derived assignments require a different mechanism; this example uses the ordinary mutable default to keep that distinction clear.
- __post_init__ runs after the generated initializer
- ValueError rejects invalid state
- field(init=False) declares a derived attribute
- Type annotations alone do not validate runtime values
See it step by step
Read the code, predict the output, then compare it with the result.
01. The generated initializer calls the hook
from dataclasses import dataclass
@dataclass
class Pair:
left: int
right: int
def __post_init__(self):
print('initialized:', self.left, self.right)
Pair(2, 3)initialized: 2 3There is no manual call to __post_init__; the generated initializer invokes it after field assignment.
02. Reject an impossible range
from dataclasses import dataclass
@dataclass
class Range:
low: int
high: int
def __post_init__(self):
if self.low > self.high:
raise ValueError('low must not exceed high')
try:
Range(8, 2)
except ValueError as exc:
print(exc)low must not exceed highInvalid state is rejected during construction rather than being stored for later failure.
03. Calculate a derived field
from dataclasses import dataclass, field
@dataclass
class Range:
low: int
high: int
span: int = field(init=False)
def __post_init__(self):
if self.low > self.high:
raise ValueError('low must not exceed high')
self.span = self.high - self.low
print(Range(4, 4).span)0Equal bounds are valid; span is assigned after validation and is not a constructor argument.
A closer look
Follow the reasoning, inspect each result, then try the suggested changes in the console below.
See when the hook runs
A dataclass generates an initializer from annotated fields. The initializer assigns the incoming values, then invokes __post_init__ if the class defines it. This hook can examine multiple fields together. It is particularly useful when one field is valid only in relation to another, such as a lower bound that must not exceed an upper bound. The code below prints both assigned values without writing a manual __init__ method.
Run the example once, then change the two values. The printed values show that the hook sees the initial state. A hook is not called after every later assignment, so it should not be mistaken for a property setter. If the object must remain valid after mutation, control writes through methods or redesign it as immutable. For this lesson the object is constructed once and its bounds are read afterward.
from dataclasses import dataclass
@dataclass
class Pair:
left: int
right: int
def __post_init__(self):
print('initialized:', self.left, self.right)
Pair(2, 3)initialized: 2 3- Let the generated initializer assign fields.
- Observe both fields inside __post_init__.
Reject a reversed range immediately
Type annotations say that low and high are intended to be integers; they do not say which is smaller. The first broken Range accepts low eight and high two, leaving an object whose meaning is unclear. Add a comparison in __post_init__ and raise ValueError when the relationship is violated. This makes the error visible at the construction site, close to the invalid input, rather than after the object has moved through more code.
The demonstration catches ValueError only so the video can show the message and continue. In an application, the caller may display that message in a form or log a configuration failure. Do not silently swap the bounds unless the product explicitly defines that behavior. Do not use assert here, since optimized Python can remove assertions. Runtime input rules need ordinary conditional code and an explicit exception.
from dataclasses import dataclass
@dataclass
class Range:
low: int
high: int
def __post_init__(self):
if self.low > self.high:
raise ValueError('low must not exceed high')
try:
Range(8, 2)
except ValueError as exc:
print(exc)low must not exceed high- Compare low and high in the hook.
- Raise ValueError before any derived state is assigned.
Derive a field after validation
A range span depends entirely on its bounds. If callers supplied all three values, someone could request Range(2, 8, 999). Declare span with field(init=False) so it belongs to the dataclass but is not a constructor parameter. After checking low <= high, calculate high minus low. Equal bounds are valid and produce zero. This small design keeps one source of truth at creation time and reduces accidental inconsistency.
Try Range(4, 4) and then Range(2, 8). Both should construct, with spans zero and six. Try the reversed case and see construction fail. Later direct assignment to low still would not recalculate span in this mutable version; mention that limit whenever exposing the class publicly. A frozen dataclass uses a different assignment mechanism, which belongs in a separate lesson. The task focuses on the ordinary generated initializer and its post-init hook.
from dataclasses import dataclass, field
@dataclass
class Range:
low: int
high: int
span: int = field(init=False)
def __post_init__(self):
if self.low > self.high:
raise ValueError('low must not exceed high')
self.span = self.high - self.low
print(Range(4, 4).span)0- Declare span with field(init=False).
- Set span only after the bounds pass validation.
Try it in Python
Edit the example and run it. Python starts in your browser the first time you click Run.
Python console
Ready to runNeed input()? Add one value per line
Your output appears here.
Need a hint?
At the start of __post_init__, check if self.low > self.high and raise ValueError with the requested message; then set self.span.
Complete the task and select Check task to verify your code.
Quick quiz
Three questions. You can change your answers and try again.
Typical mistakes
Everyone meets these errors. See what causes them and how to fix them.
Assuming annotations enforce values
@dataclass
class Range:
low: int
high: intdef __post_init__(self):
if self.low > self.high:
raise ValueError('low must not exceed high')What happens: Range(8, 2) constructs even though the bounds are reversed.
Annotations document intended types; explicit logic enforces the relationship.
Making derived state a caller argument
span: int = 0span: int = field(init=False)
# Set it in __post_init__.What happens: The caller may pass a span inconsistent with the bounds.
Derive the value from low and high in one place.