Models and validation
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.
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. Setmodel_config = ConfigDict(extra="forbid")on any model that accepts external input. str | Noneis not optional. A field typedstr | Nonewith no default is still required; you must send it, even if the value isnull. Add= Noneto make it optional.- Bool coercion is generous.
"yes","on","t"and1all becomeTrue. If a flag comes from a form, decide whether that is what you want, or use strict mode for that field. model_constructskips 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
Ticketin 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.
Continue with the complete track
Keep your progress and unlock the surrounding lessons, exercises, and complete learning path.
Unlock the complete track