04.01

Models and validation

Lesson 01 of 5 · 4:35
skillmaxing
Python with AI
Lesson 01
Models and validation
0:00 / 4:351x
Notes

Every backend has a boundary where data arrives from somewhere you do not control: a JSON body from a browser, a query string, a row from a database driver, the arguments a language model decided to pass to a tool. Pydantic's job is to stand at that boundary. You describe the shape you expect as a class with type hints, and Pydantic checks incoming data against it, converts what it reasonably can, and raises one detailed error that lists everything wrong at once.

Past that line, the rest of your code works with real Python objects. A Ticket has an int id and a datetime, not "probably an int" and "some string that looked like a date". That guarantee is what lets the rest of Relay stay short.

A model is a class with typed fields

Relay's first model is a support ticket. Annotate each field with a type, give optional fields a default, and inherit from BaseModel.

Validation lives at the boundary
Figure 1Validation lives at the boundaryUntrusted input goes in one side; a typed Python object comes out the other.
from datetime import datetime
from typing import Literal
from pydantic import BaseModel

class Ticket(BaseModel):
    id: int
    customer_id: int
    subject: str
    body: str
    priority: Literal["low", "normal", "high"] = "normal"
    status: Literal["open", "pending", "closed"] = "open"
    created_at: datetime

raw = {
    "id": "42",
    "customer_id": 7,
    "subject": "Cannot export invoices",
    "body": "The export button does nothing.",
    "created_at": "2026-09-14T09:30:00Z",
}
ticket = Ticket.model_validate(raw)
print(ticket.id, type(ticket.id))   # 42 <class 'int'>
print(ticket.created_at.year)       # 2026

model_validate takes a dict (or any mapping) and returns an instance. Calling the class directly, Ticket(**raw), does the same validation with keyword arguments. Notice what happened to the input: "42" became the integer 42, and an ISO 8601 string became a timezone-aware datetime. Fields without defaults are required; priority and status fall back to their defaults when absent.

Run it with uv add pydantic and uv run ticket.py.

What a failed validation looks like

When the input is wrong, Pydantic does not stop at the first problem. It walks every field, collects every failure, and raises a single ValidationError.

from pydantic import ValidationError

try:
    Ticket.model_validate({"id": "abc", "subject": 12})
except ValidationError as e:
    print(e.error_count())   # 5
    for err in e.errors():
        print(err["loc"], err["type"], err["msg"])

That prints five lines: id failed integer parsing, subject is not a string, and customer_id, body, and created_at are missing. Each error carries a loc tuple (the path to the field, including list indexes for nested data), a stable machine-readable type, and a human msg. FastAPI later turns this list straight into a 422 response, which is why API clients get useful feedback without you writing any of it.

Engineering note. Validate once, at the edge, and then pass the model object through your code without re-checking it. Models that describe "what arrives" should be separate from models that describe "what we store" or "what we return"; sharing one class across all three is the most common way a Pydantic codebase turns into a tangle of optional fields.

Coercion, and when to turn it off

By default Pydantic runs in lax mode: it accepts values that can be converted to the declared type without loss. That is convenient at HTTP boundaries, where everything is a string anyway, but you should know the rules.

Declared type Input Result in lax mode
int "42" 42
int 3.0 3
int 3.5 error, would lose precision
bool "yes", "on", 1 True
datetime "2026-09-14T09:30:00Z" aware datetime
str 12 error, numbers are not strings

When a field must be exactly the declared type, ask for strict mode. You can switch it on for a whole model with model_config = ConfigDict(strict=True), or for one call with Ticket.model_validate(raw, strict=True). In strict mode "42" is rejected for an int, and a datetime must already be a datetime. Relay uses lax mode for HTTP input and strict mode for internal messages between services, where a string where an integer should be means a bug, not a user.

Nesting and collections

Models compose. A Customer can hold a list of tickets, and validation recurses into the list.

class Customer(BaseModel):
    id: int
    email: str
    plan: Literal["free", "team", "enterprise"]
    tickets: list[Ticket] = []

c = Customer.model_validate({
    "id": 7, "email": "ana@example.com", "plan": "team",
    "tickets": [raw, {**raw, "id": 43, "priority": "high"}],
})
print(len(c.tickets), c.tickets[1].priority)   # 2 high

If the second ticket in the list were broken, the error's loc would read ("tickets", 1, "priority"), pointing at the exact element. Pydantic copies mutable defaults like [] for every instance, so the shared-list bug that bites plain Python functions does not apply here. dict[str, int], set[str], tuple[int, int], and X | None all work the same way.

Where it goes wrong

  • Extra keys vanish silently. By default an unknown field such as "prority" is dropped without complaint, so a client's typo becomes a ticket with default priority. Set model_config = ConfigDict(extra="forbid") on any model that accepts external input.
  • str | None is not optional. A field typed str | None with no default is still required; you must send it, even if the value is null. Add = None to make it optional.
  • Bool coercion is generous. "yes", "on", "t" and 1 all become True. If a flag comes from a form, decide whether that is what you want, or use strict mode for that field.
  • model_construct skips validation. It exists for building instances from data you have already checked. Using it on request input gives you a model whose types are a lie.
  • Validating in every layer. Re-validating a Ticket in the service layer, the repository, and the response costs time and hides where the boundary actually is.

Try it

Create relay/models.py with the Ticket and Customer models above, set extra="forbid" on both, and write a pytest file with three tests: a valid ticket round-trips, a string id is coerced to an integer, and a dict with a misspelled prority key raises ValidationError with exactly one error whose type is extra_forbidden. Run it with uv run pytest.

Types alone cannot say that a subject must be under two hundred characters or that a closed ticket needs a resolution, which is the job of field constraints and custom validators in the next lesson.

Free preview

Continue with the complete track

Keep your progress and unlock the surrounding lessons, exercises, and complete learning path.

Unlock the complete track
124 online