Pydantic Models & Validation
In Phase 2 you saw how a type hint on a path or query parameter quietly does real work: FastAPI reads the hint and parses, validates, and converts the value for you. That trick has a name, and it's a whole library — Pydantic. Path and query parameters are the small version; the full power shows up when a client sends you a JSON body and you need to know, with confidence, that it has the right shape before you touch it.
Here's the mental model to carry through this phase: a Pydantic model is a typed gate. You describe the
shape of the data once — a class with typed fields — and Pydantic stands at the door, checking every piece
of data that tries to come in. Good data passes through as a clean, typed Python object. Bad data gets
turned away with a precise error. You stop writing if not isinstance(...) checks by hand; the shape is
the check.
What Pydantic actually is
📝 Pydantic is a data-validation library. You define a class that extends BaseModel, give it typed
fields, and Pydantic validates and coerces any data you build it from against those types — at runtime.
This is the crucial difference from the type hints you met in Python: a plain
hint like age: int is a note for humans and tools that the interpreter ignores while running. Pydantic
enforces the same hint when the object is constructed.
The other thing to know up front: Pydantic is separate from FastAPI. It's its own library, usable in any
Python program — config loading, parsing files, cleaning data. FastAPI just leans on it hard: every request
body you'll define is a Pydantic model. That separation is good news for you right now, because it means
the examples in this phase are pure Python and run on this page. No server, no uvicorn — you can watch
validation happen and succeed and fail, live.
💡 Key point. A Pydantic model is the same describe-the-fields idea as a dataclass (Python From Zero, Phase 15), with one decisive addition: it checks and converts the data at construction time instead of trusting it. Dataclass for data you already trust; Pydantic at the boundary where untrusted data arrives.
Your first model — and watch it reject bad data
Let's model a Book for our book service: a title, an author, a year, and a price. Extending BaseModel
and listing typed fields is the whole definition. This block runs — build a book from a dict, print it, then
feed it garbage and see what Pydantic does.
:
:
:
:
# Good data — Pydantic builds a clean, typed object:
=
=
# Bad data — year isn't a number, price is missing entirely:
What just happened: the first Book(**data) sailed through — Pydantic checked each field against its type
and handed you a real Book object with .title, .author, .year, and .price attributes. The second
attempt raised a ValidationError, and look at how specific it is: it tells you year couldn't be parsed
as an integer and that price is required but missing — both problems, in one report, pointing at the
exact fields. You didn't write a single validation check. The class is the validation.
Contrast that with a plain dataclass, which trusts whatever you give it:
:
:
:
:
# The dataclass happily stores nonsense — the `: int` hint is never enforced:
=
# it's a str, not an int — the bug is now inside your object
What just happened: the dataclass accepted year="not-a-year" and price="free" without complaint and
stored them as strings. The : int and : float hints were ignored at runtime — exactly as Python type
hints always are. The bad data is now sitting inside your object, waiting to blow up somewhere later when
you try arithmetic on a string. That's the gap Pydantic closes: it moves the failure to the boundary,
where it's cheap to diagnose, instead of letting it leak deep into your code.
Field constraints — rules that live with the type
Type-correct isn't the same as valid. A price of -5.0 is a perfectly good float and a perfectly absurd
price. A year of 99 parses as an int but no book was printed then. Pydantic lets you attach constraints
to a field with Field(...), so the rule lives right next to the type it guards.
: = # no empty titles
: =
: = # between 1450 and 2100 inclusive
: = # strictly greater than 0
# Valid — every constraint satisfied:
=
# Invalid — empty title, year too early, price not positive:
What just happened: the valid book passed because it cleared every rule. The invalid one tripped three
constraints at once — title was empty (min_length=1), year of 1200 fell below ge=1450, and price
of 0 failed gt=0 (greater than, not greater-or-equal) — and Pydantic reported all three with the limits
it expected. gt is "greater than," ge is "greater than or equal," le is "less than or equal" (and lt
exists too); min_length works on strings and lists.
💡 This is declarative validation: you declare what valid looks like as part of the field, and Pydantic
figures out how to check it. The rule and the data it protects never drift apart — change the field, the
constraint moves with it. Compare that to scattering hand-written if price <= 0: raise ... checks across
every function that touches a book.
Using a model as a request body
Now the payoff for FastAPI. In Phase 2, a parameter typed as a simple
type (int, str) became a path or query parameter. Here's the rule that completes the picture: when you
type a parameter as a Pydantic model, FastAPI reads it from the JSON request body. It pulls the raw JSON,
hands it to your model for validation, and — if it passes — gives your function a fully typed object. If it
fails, FastAPI never even calls your function; it returns a 422 Unprocessable Entity automatically, with
the same precise error detail you saw above.
This endpoint code needs a running server, so it's shown as plain Python (run it yourself with the commands from Phase 1):
=
: =
: =
: =
: =
# typed as the model → comes from the JSON body
# `book` is already validated. No checks needed here.
return
What just happened: the single line book: Book did everything. FastAPI saw a parameter typed as a
BaseModel, so it knew to read the request body, validate it against Book, and pass you a ready-to-use
object. Inside create_book there are zero validation checks — by the time your code runs, the data is
guaranteed valid. That guarantee is the whole point: the gate is at the door, not scattered through the house.
A valid request — this JSON body sails through and your function runs:
An invalid one — year is below the allowed range and price isn't positive — never reaches your function.
FastAPI returns 422 with a body like this:
What just happened: FastAPI turned your model's ValidationError into a clean HTTP 422 response. Each
entry in detail points at the offending field via loc (["body", "year"] means "the year field in the
request body"), explains what was expected, and echoes the bad input. The client gets a genuinely useful
error, and you wrote none of it — it fell straight out of the model definition.
Coercion, optionals, and nesting
A few behaviors round out the mental model.
📝 Coercion. Pydantic doesn't just check types — it converts compatible ones. Hand it the string
"2020" for an int field and it gives you the integer 2020. This is exactly why JSON works smoothly:
numbers arriving as strings get tidied up. But it only coerces what's sensibly convertible — "not-a-year"
has no integer meaning, so it's rejected rather than guessed at.
:
:
:
# Strings that *look* like numbers get coerced to the declared type:
=
# int and float — converted, not stored as str
What just happened: you passed year and price as strings, and Pydantic coerced them to a real int
and float because those strings have an unambiguous numeric meaning. The printed types confirm the
conversion. Try changing "1965" to "nineteen" and you'll get a ValidationError instead — coercion has
limits, and gibberish hits them.
⚠️ Coercion can surprise you. Lax coercion is convenient but occasionally too generous — depending on
configuration, things like "1" might slip into a bool, or a float might be quietly truncated. If you ever
need exact-type-only behavior (no string-to-int favors), Pydantic offers strict mode to turn coercion
off per-field or per-model. For now, know that the default is lax and helpful, and that strict mode exists
for when "helpful" isn't what you want.
Optional fields with defaults. Give a field a default value and it becomes optional — callers can leave
it out. Use X | None = None for "might genuinely be absent."
:
:
: = True # optional: defaults to True if omitted
: | None = None # optional and nullable
What just happened: in_stock and discount both have defaults, so the first Book(...) — which supplies
only title and author — is completely valid; Pydantic filled in in_stock=True and discount=None. The
second call overrode both. Required fields are the ones without a default.
Nesting. A model field can be typed as another model. Pydantic validates the whole tree — outer object, inner object, all the way down.
:
:
:
: # a field whose type is another model
:
=
=
# nested object, fully typed
What just happened: author: Author told Pydantic the author field is itself a model, so it validated the
nested {"name": ..., "country": ...} dict against Author and gave you book.author as a real Author
object — hence book.author.name works with full typing. Mistype anything inside the nested dict and you'd
get a ValidationError pointing at the nested path, like ["author", "country"].
💡 The payoff, stated plainly. Define the shape once as a model, and everything follows from it:
validation (this phase), automatic 422 errors, the interactive docs that show the exact schema, and — next
phase — serialization of your responses. One definition, many free features. That's the FastAPI promise from
the overview made concrete: types are the contract.
Recap
- Pydantic is a runtime data-validation library — define a class extending
BaseModelwith typed fields, and it validates and coerces data against those types when the object is built. It's separate from FastAPI and pure Python, so its examples run anywhere. - A
ValidationErroris precise — it names every bad field at once, says what was expected, and (in FastAPI) becomes an automatic422with the same detail. - Constraints live with the field via
Field(...):gt/ge/lt/lefor numbers,min_lengthfor strings and lists. Declarative — the rule never drifts from the data it guards. - A parameter typed as a model = the request body. FastAPI reads the JSON, validates it against the
model, and hands your function a clean typed object — or returns
422and never calls you. - Coercion converts compatible types (
"2020"→2020) but rejects nonsense; the default is lax, and strict mode exists when you need exact types. - Optional fields get defaults (
in_stock: bool = True, orX | None = None); nested models let one model contain another, validated all the way down.
Next phase flips the direction: instead of validating data coming in, you'll use models to shape and control the data going out — response models, hidden fields, and honest status codes.
Quick check
Three questions on the ideas that have to stick — what Pydantic enforces, where a model body comes from, and what coercion does.
[
{
"q": "You define `class Book(BaseModel)` with `price: float` and call `Book(title=\"X\", author=\"Y\", year=2000, price=\"oops\")`. What happens?",
"choices": [
"It builds the object and stores \"oops\" as the price",
"It raises a ValidationError — \"oops\" can't be coerced to a float",
"Python raises a TypeError before Pydantic sees it",
"It silently sets price to 0.0"
],
"answer": 1,
"explain": "Unlike a plain dataclass (which would store the string), Pydantic enforces the type at construction. \"oops\" has no sensible float meaning, so coercion fails and a ValidationError is raised — pointing at the price field."
},
{
"q": "In FastAPI, what makes a function parameter come from the JSON request body rather than the path or query string?",
"choices": [
"Naming the parameter `body`",
"Adding `@app.post` instead of `@app.get`",
"Typing the parameter as a Pydantic BaseModel",
"Wrapping it in `Body(...)` — there's no other way"
],
"answer": 2,
"explain": "FastAPI's rule: a parameter typed as a Pydantic model is read from the request body, validated against the model, and passed in as a typed object (or it auto-returns 422). The HTTP method and parameter name don't determine this."
},
{
"q": "A field is declared `year: int`. A client sends the JSON value `\"1965\"` (a string). With Pydantic's default behavior, what does your object's `year` end up as?",
"choices": [
"The string \"1965\" — Pydantic never changes types",
"A ValidationError, because a string isn't an int",
"The integer 1965 — Pydantic coerces compatible types",
"None, because the value didn't match exactly"
],
"answer": 2,
"explain": "By default Pydantic is lax: it coerces sensibly-convertible values, so the string \"1965\" becomes the integer 1965. (Gibberish like \"nineteen\" would still raise. Strict mode exists if you want to forbid the conversion.)"
}
]
← Phase 2: Path Operations & Parameters · Guide overview · Phase 4: Response Models & Status Codes →
Check your understanding
1. You define `class Book(BaseModel)` with `price: float` and call `Book(title="X", author="Y", year=2000, price="oops")`. What happens?
2. In FastAPI, what makes a function parameter come from the JSON request body rather than the path or query string?
3. A field is declared `year: int`. A client sends the JSON value `"1965"` (a string). With Pydantic's default behavior, what does your object's `year` end up as?