Typorithm · medium

Model validators enforce relationships between fields

pythonpydanticindependent-practice
Saved on this device
Full lesson · 4 learning steps Open any exercise for focused practice
1

Step 1 of 4 · intro card

Model validators enforce relationships between fields

0%

Concept and contract

Check a range after its individual fields have passed their own contracts.

An individually valid field can still form an invalid object together with another field. A lower and upper bound can both be nonnegative integers while the lower value exceeds the upper. A model validator expresses this relationship without depending on which field happens to be declared first.

The Range model starts with two local integer bounds and one cross-field rule: low must not exceed high. The baseline permits an equal-bound range. Write down whether that means a point, an empty interval or an error in the application domain; the model cannot infer the intended interval convention from the field names.

Mechanism and worked example

An after model validator receives the checked model instance.

An after model validator receives the checked model instance. It compares low and high and returns self when the invariant holds. A failure of an individual Field constraint is reported before this successful-model invariant can be established, which keeps type or lower-bound mistakes separate from the relationship rule.

Follow low=2, high=5 through both stages, then compare low=9, high=4. The second mapping passes the local nonnegative checks and fails only the model relationship. The fixture also includes a negative low, demonstrating that the model-level rule does not replace field-local validation.

Complete worked example

python

from typing import Self
from pydantic import BaseModel, Field, ValidationError, model_validator

class Range(BaseModel):
    low: int = Field(ge=0)
    high: int = Field(ge=0)

    @model_validator(mode="after")
    def ordered(self) -> Self:
        if self.low > self.high:
            raise ValueError("low exceeds high")
        return self

def inspect_example(payload):
    try:
        return {"value": Range.model_validate(payload).model_dump()}
    except ValidationError as error:
        return {"error": sorted({e["type"] for e in error.errors()})}
Reasoning
An after model validator receives the checked model instance. It compares low and high and returns self when the invariant holds. A failure of an individual Field constraint is reported before this successful-model invariant can be established, which keeps type or lower-bound mistakes separate from the relationship rule. Follow low=2, high=5 through both stages, then compare low=9, high=4. The second mapping passes the local nonnegative checks and fails only the model relationship. The fixture also includes a negative low, demonstrating that the model-level rule does not replace field-local validation.

Focused decision: use the complete example for context

python

from typing import Self
from pydantic import BaseModel, model_validator

class Range(BaseModel):
    low: int
    high: int

    @model_validator(mode="after")
    def ordered(self) -> Self:
        if self.low > self.high:
            raise ValueError("low exceeds high")
        return self
Focused decision
Reconstruct this focused decision after the warm-up. The complete imports, model or application, and test client remain in the worked example. Typing records practice; only the downloadable computer lab executes Python.

Counterexamples and common mistakes

Reading a second field through a field validator can depend on field declaration order and on whether that other field validated successfully.

Reading a second field through a field validator can depend on field declaration order and on whether that other field validated successfully. Another common mistake is returning None from an after model validator, or changing fields inside it while validate_assignment recursively invokes more validation.

Use a clear model-level check when the invariant belongs to the object. The controlled counterexample reverses the comparison, a plausible one-character mistake that rejects a valid ordered range. The expected values are independently stated in the fixture, so the test does not merely reproduce whatever comparison the implementation happens to contain.

Controlled mistake: observe the actual wrong behavior

python

from typing import Self
from pydantic import BaseModel, Field, ValidationError, model_validator

class Range(BaseModel):
    low: int = Field(ge=0)
    high: int = Field(ge=0)

    @model_validator(mode="after")
    def ordered(self) -> Self:
        if self.low < self.high:
            raise ValueError("low exceeds high")
        return self

def inspect_example(payload):
    try:
        return {"value": Range.model_validate(payload).model_dump()}
    except ValidationError as error:
        return {"error": sorted({e["type"] for e in error.errors()})}
Reasoning
Reading a second field through a field validator can depend on field declaration order and on whether that other field validated successfully. Another common mistake is returning None from an after model validator, or changing fields inside it while validate_assignment recursively invokes more validation. Use a clear model-level check when the invariant belongs to the object. The controlled counterexample reverses the comparison, a plausible one-character mistake that rejects a valid ordered range. The expected values are independently stated in the fixture, so the test does not merely reproduce whatever comparison the implementation happens to contain.

Independent practice: one bounded change

Change the range policy to require a positive width: low must be strictly less than high.

Change the range policy to require a positive width: low must be strictly less than high. Keep nonnegative field constraints and preserve the output shape. An equal-bound range should now fail, while a normal ordered range should still validate.

Make one comparison change and reason about equality explicitly. Test low=2, high=2 before and after the edit, then include a valid interval and a negative input. A solution that subtracts or reorders the submitted values would conceal the caller error and would no longer be validating the stated range.

Step 1: Require low < high rather than low <= high; preserve the field constraints. Step 2: Retain the working imports and application structure. Change the stated policy, run the supplied practice cases, then explain the newly accepted or rejected boundary.

Practice starter: solve before reading the solution

python

from typing import Self
from pydantic import BaseModel, Field, ValidationError, model_validator

class Range(BaseModel):
    low: int = Field(ge=0)
    high: int = Field(ge=0)

    @model_validator(mode="after")
    def ordered(self) -> Self:
        if self.low > self.high:
            raise ValueError("low exceeds high")
        return self

def inspect_example(payload):
    try:
        return {"value": Range.model_validate(payload).model_dump()}
    except ValidationError as error:
        return {"error": sorted({e["type"] for e in error.errors()})}

# Independent task: Require low < high rather than low <= high; preserve the field constraints.
Reasoning
Require low < high rather than low <= high; preserve the field constraints.
  • Hint 1: Equality is the only newly rejected valid baseline boundary.
  • Hint 2: Change > to >= in the relationship check.

Solution, tests and trade-offs

The solution rejects low >= high and returns self otherwise.

The solution rejects low >= high and returns self otherwise. The practice cases retain one valid range, introduce equality as a new rejection, and keep the negative-field failure visible. This makes the policy change testable without conflating it with parsing, sorting or serialization.

The range invariant is local to the object at one moment. A later unchecked copy or mutation can still break it, which is why a subsequent lesson examines model_copy and validated updates. A model also cannot prove that an external resource is still available when a transaction commits; temporal and concurrent invariants require a different boundary.

Practice solution and acceptance checks

python

from typing import Self
from pydantic import BaseModel, Field, ValidationError, model_validator

class Range(BaseModel):
    low: int = Field(ge=0)
    high: int = Field(ge=0)

    @model_validator(mode="after")
    def ordered(self) -> Self:
        if self.low >= self.high:
            raise ValueError("low exceeds high")
        return self

def inspect_example(payload):
    try:
        return {"value": Range.model_validate(payload).model_dump()}
    except ValidationError as error:
        return {"error": sorted({e["type"] for e in error.errors()})}
Reasoning
The solution rejects low >= high and returns self otherwise. The practice cases retain one valid range, introduce equality as a new rejection, and keep the negative-field failure visible. This makes the policy change testable without conflating it with parsing, sorting or serialization. The range invariant is local to the object at one moment. A later unchecked copy or mutation can still break it, which is why a subsequent lesson examines model_copy and validated updates. A model also cannot prove that an external resource is still available when a transaction commits; temporal and concurrent invariants require a different boundary.

Interview reasoning and next step

Explain the difference between a field invariant and an object invariant in a code review.

Explain the difference between a field invariant and an object invariant in a code review. Give one mapping that fails each stage and say why. A reviewer should be able to change the order of field declarations without changing the intended relationship check.

Continue by inspecting nested model errors. For a large request, a useful response must identify where a failure occurred without leaking the complete submitted payload. The same discipline applies here: preserve structured error information, distinguish the relationship failure, and avoid replacing every validation issue with an unhelpful generic success or empty object.

Optional stretch — after the core task works: Add one independently reasoned boundary case and explain its expected value before running it.

Computer practice: https://applydjinn.com/typorithm/python-api-lab-20261105.zip. Use an isolated Python environment and the pinned dependencies. Run verify.py --track pydantic, then edit the working exercise baseline and run --practice pyd-model-validators. The reference suite rejects meaningful counterexamples and checks values, types and unchanged inputs. All data is owned and all HTTP clients are in-process. No production database, feed, account or paid service is used.

Check a range after its individual fields have passed their own contracts.