Typorithm · easy

Models validate a boundary, not a Python annotation

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

Step 1 of 4 · intro card

Models validate a boundary, not a Python annotation

0%

Concept and contract

Turn an untrusted mapping into a checked model and inspect the normalized output.

A Python annotation describes an intended value to readers and static tools; it does not automatically check a dictionary received from JSON. A BaseModel creates a runtime boundary when model_validate is called. The useful result is a model whose fields satisfy the configured contract, not a claim that the original object was already trustworthy.

Our role example receives a title and a count of slots. Normal validation can turn the string "3" into integer 3. That conversion is an explicit library policy, not proof that every string or number is appropriate. Start by writing down the accepted input and the normalized output before deciding whether coercion is desirable.

Mechanism and worked example

The Role class names two fields.

The Role class names two fields. inspect_example sends the mapping through model_validate and then returns model_dump, making the converted values visible to the fixture. ValidationError is caught only to report stable error types; arbitrary internal exceptions are not transformed into a successful result.

Follow the slots value across the boundary: external text arrives, integer validation runs, then the output mapping contains an integer. A nonsensical value such as "many" never becomes a valid Role. The original input mapping remains unchanged. The lab checks that immutability separately from the expected return value.

Complete worked example

python

from pydantic import BaseModel, Field, ValidationError

class Role(BaseModel):
    title: str
    slots: int

def inspect_example(payload):
    try:
        role = Role.model_validate(payload)
        return {"ok": True, "role": role.model_dump()}
    except ValidationError as error:
        return {"ok": False, "types": sorted({e["type"] for e in error.errors()})}
Reasoning
The Role class names two fields. inspect_example sends the mapping through model_validate and then returns model_dump, making the converted values visible to the fixture. ValidationError is caught only to report stable error types; arbitrary internal exceptions are not transformed into a successful result. Follow the slots value across the boundary: external text arrives, integer validation runs, then the output mapping contains an integer. A nonsensical value such as "many" never becomes a valid Role. The original input mapping remains unchanged. The lab checks that immutability separately from the expected return value.

Focused decision: use the complete example for context

python

from pydantic import BaseModel

class Role(BaseModel):
    title: str
    slots: int

def parse_role(payload):
    return Role.model_validate(payload)
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

model_construct is a trusted-data tool that skips normal validation.

model_construct is a trusted-data tool that skips normal validation. It is useful only when another reliable boundary has already established the invariant. Substituting it for model_validate at an external API boundary can keep slots as a string or admit values the application cannot handle.

Also distinguish shape validation from business policy. A plain integer field does not reject zero or a negative number. The initial example deliberately demonstrates that limitation. Extra input is ignored by default; rejecting unexpected keys needs an explicit configuration, introduced in a later lesson. A successfully constructed object is not an authorization decision.

Controlled mistake: observe the actual wrong behavior

python

from pydantic import BaseModel, Field, ValidationError

class Role(BaseModel):
    title: str
    slots: int

def inspect_example(payload):
    try:
        role = Role.model_construct(**payload)
        return {"ok": True, "role": role.model_dump()}
    except ValidationError as error:
        return {"ok": False, "types": sorted({e["type"] for e in error.errors()})}
Reasoning
model_construct is a trusted-data tool that skips normal validation. It is useful only when another reliable boundary has already established the invariant. Substituting it for model_validate at an external API boundary can keep slots as a string or admit values the application cannot handle. Also distinguish shape validation from business policy. A plain integer field does not reject zero or a negative number. The initial example deliberately demonstrates that limitation. Extra input is ignored by default; rejecting unexpected keys needs an explicit configuration, introduced in a later lesson. A successfully constructed object is not an authorization decision.

Independent practice: one bounded change

Change one field policy: a role must have at least one slot.

Change one field policy: a role must have at least one slot. Retain the imports, class, error reporting and output shape, and add a lower bound to slots. There is no need to invent a new parser or to convert values twice.

Run the supplied practice cases for one, zero and a numeric string. Predict which inputs still normalize and which now fail before executing them. The task changes the accepted domain while preserving the useful normalization policy. Do not hide the zero by replacing it with a default or removing the field.

Step 1: Require at least one slot by adding one Field constraint. Preserve ordinary integer normalization. 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 pydantic import BaseModel, Field, ValidationError

class Role(BaseModel):
    title: str
    slots: int

def inspect_example(payload):
    try:
        role = Role.model_validate(payload)
        return {"ok": True, "role": role.model_dump()}
    except ValidationError as error:
        return {"ok": False, "types": sorted({e["type"] for e in error.errors()})}

# Independent task: Require at least one slot by adding one Field constraint. Preserve ordinary integer normalization.
Reasoning
Require at least one slot by adding one Field constraint. Preserve ordinary integer normalization.
  • Hint 1: Field is already imported; attach the constraint to slots.
  • Hint 2: ge means greater than or equal. Keep the field type int.

Solution, tests and trade-offs

The solution uses Field with ge=1 on the existing integer field.

The solution uses Field with ge=1 on the existing integer field. The count is validated after conversion, so a valid numeric string still produces an integer, while zero produces a greater_than_equal error. The acceptance fixture compares exact JSON-compatible values and error types.

A lower bound is only the policy stated here. It does not check remaining capacity, identify the caller, or stop two concurrent bookings. Those require application state and transaction design. When reviewing a change, name the invariant that this model establishes and the important invariants it deliberately leaves to other layers.

Practice solution and acceptance checks

python

from pydantic import BaseModel, Field, ValidationError

class Role(BaseModel):
    title: str
    slots: int = Field(ge=1)

def inspect_example(payload):
    try:
        role = Role.model_validate(payload)
        return {"ok": True, "role": role.model_dump()}
    except ValidationError as error:
        return {"ok": False, "types": sorted({e["type"] for e in error.errors()})}
Reasoning
The solution uses Field with ge=1 on the existing integer field. The count is validated after conversion, so a valid numeric string still produces an integer, while zero produces a greater_than_equal error. The acceptance fixture compares exact JSON-compatible values and error types. A lower bound is only the policy stated here. It does not check remaining capacity, identify the caller, or stop two concurrent bookings. Those require application state and transaction design. When reviewing a change, name the invariant that this model establishes and the important invariants it deliberately leaves to other layers.

Interview reasoning and next step

In an interview, explain the difference between a type annotation, runtime validation, normalization and a domain rule.

In an interview, explain the difference between a type annotation, runtime validation, normalization and a domain rule. Walk through the valid string input and then the invalid text input instead of saying that a library makes all data safe.

The next lessons make absent values, explicit nulls and unknown fields visible. As you learn each option, connect it to a concrete consumer requirement. Keep the policy at one boundary so downstream code can rely on a clearly named model rather than repeatedly guessing what kind of dictionary it received.

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-models. 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.

Turn an untrusted mapping into a checked model and inspect the normalized output.