Pydantic is the part of FastAPI actually doing the work. Every request body, every response shape, every query constraint from the last lesson is a pydantic model underneath. This lesson is about using it deliberately — not just as a way to declare fields, but as the place your data rules live.
Everything here is pydantic v2. That matters: v1 examples are still everywhere,
several method names changed, and the two are not compatible. If a snippet you find online uses
@validator, .dict() or orm_mode, it is v1.
A model is a parser, not a class
The mental shift that makes pydantic click: a model does not describe data you already have, it produces valid data from data you do not trust.
class QuoteRequest(ApiModel):
property_id: UUID
check_in: date
check_out: date
guests: int = Field(default=1, ge=1, le=50)Hand that JSON strings and it returns a UUID and two date objects. The
conversion is the feature. By the time your function runs, check_out - check_in is
valid arithmetic, not a string comparison you have to remember to parse first.
Fields with no default are required. A default makes them optional. And the distinction that catches everyone:
class Example(BaseModel):
a: str # required
b: str = "x" # optional, defaults to "x"
c: str | None # REQUIRED, and may be null
d: str | None = None # optional, defaults to nullc is required. | None controls whether null is an
acceptable value; the presence of a default controls whether the field can be
omitted. They look like the same idea and are not.
Field constraints
Field() carries the rules. StayHub's listing model is a good survey:
class PropertyCreateRequest(ApiModel):
title: str = Field(min_length=5, max_length=200)
description: str = Field(default="", max_length=5000)
property_type: PropertyType = PropertyType.HOUSE
room_type: RoomType = RoomType.ENTIRE_PLACE
address_line1: str = Field(default="", max_length=255)
city: str = Field(min_length=1, max_length=120)
state: str | None = Field(default=None, max_length=120)
country: str = Field(default="United States", max_length=120)
postal_code: str | None = Field(default=None, max_length=20)
latitude: Decimal | None = None
longitude: Decimal | None = None
price_per_night: Decimal = Field(gt=0, le=Decimal("100000"))
cleaning_fee: Decimal = Field(default=Decimal("0"), ge=0, le=Decimal("100000"))
max_guests: int = Field(default=2, ge=1, le=50)
bedrooms: int = Field(default=1, ge=0, le=50)
beds: int = Field(default=1, ge=0, le=50)
bathrooms: Decimal = Field(default=Decimal("1"), ge=0, le=Decimal("50"))
amenity_slugs: list[str] = []
images: list[PropertyImageInput] = []Three things there are worth doing in your own models.
Every string is bounded. max_length on all of them, not because
anyone types a 5,000-character city name, but because without it a request can. An unbounded string
field is an unbounded row and, further down, an unbounded index entry.
Every number has a floor and a ceiling. bedrooms: int = Field(ge=0,
le=50) makes -1 and 10**9 impossible. Note ge=0 for
bedrooms (a studio has none) but ge=1 for max_guests (a listing that
sleeps nobody is not a listing). The bounds encode domain knowledge, which is why they belong here
rather than in a service.
Money is Decimal, never float.
0.1 + 0.2 != 0.3 in binary floating point, and a booking total is the last place you
want that. The same rule holds all the way down — the database column is
Numeric(10, 2). Coordinates are Decimal too, because a round-trip through
a float turns 37.7749 into 37.774899999999995.
What gets coerced, and what does not
Pydantic v2 converts between compatible types but is stricter than v1 about what "compatible" means. The rules are worth knowing because the surprises are all at the boundary:
class Demo(BaseModel):
n: int
flag: bool
when: date
Demo(n="42", flag="yes", when="2027-03-01")
# -> n=42, flag=True, when=datetime.date(2027, 3, 1) strings parse
Demo(n=42.0, flag=1, when="2027-03-01")
# -> n=42, flag=True lossless float -> int is fine
Demo(n=42.7, flag=True, when="2027-03-01")
# -> ValidationError: Input should be a valid integer,
# got a number with a fractional part42.0 becomes 42 because nothing is lost; 42.7 is an error
rather than a silent truncation. That is the principle throughout — lossless
conversions happen, lossy ones raise. v1 would have given you 42 and said
nothing.
Booleans accept the strings you would expect ("yes", "true",
"on", "1") and reject the ones you would not. If you want none of this,
model_config = ConfigDict(strict=True) turns coercion off entirely and requires exact
types — occasionally right for an internal service, usually wrong for an HTTP API where
everything arrives as a string anyway.
Nested models
A model can contain models, and validation recurses. StayHub's listing takes a list of images:
class PropertyImageInput(ApiModel):
url: str = Field(max_length=500)
alt_text: str | None = Field(default=None, max_length=255)
is_cover: bool = FalseDeclared on the parent as images: list[PropertyImageInput] = [], that validates
every element, and the error location points at the offending one:
{"detail":[{"type":"string_too_long","loc":["body","images",2,"url"],
"msg":"String should have at most 500 characters"}]}["body", "images", 2, "url"] — the third image's URL. That precision is free,
and it is why nesting models beats accepting list[dict] and checking by hand.
One caution: nesting has no depth limit, and a deeply nested model is a deeply recursive
validation on untrusted input. If a client can control the shape as well as the values, bound it
— Field(max_length=50) on the list is one line and stops somebody posting fifty
thousand images.
Validators, for rules a constraint cannot express
Two kinds, and choosing the right one matters. A field validator sees one field:
@field_validator("price_per_night", "cleaning_fee")
@classmethod
def two_decimal_places(cls, v: Decimal) -> Decimal:
return v.quantize(Decimal("0.01"))Note it returns a value. A validator can normalise as well as reject, and this one does
only that: quantizing at the edge means a client sending 129.999 cannot create a
listing whose price renders as $130.00 but sums as something else. Doing it here rather
than at each use means there is one place it can be forgotten, and it is not forgotten.
A model validator sees the whole object, which is what you need when a rule spans fields:
@model_validator(mode="after")
def check_dates(self) -> "QuoteRequest":
if self.check_out <= self.check_in:
raise ValueError("Check-out must be after check-in.")
return selfmode="after" runs once every field has been parsed, so self.check_in
is a real date and the comparison is meaningful. mode="before" runs on the
raw input instead — use it to reshape incoming data, not to validate it.
Raise a plain ValueError. Pydantic catches it, and FastAPI turns it into a 422
naming the field. Raising an HTTPException from a validator works but couples your data
model to the web layer, which stops it being usable anywhere else.
The camelCase boundary
Python is snake_case. TypeScript is camelCase. Neither community is going to change, and hand- converting at every call site is how the two spellings end up mixed in one payload.
StayHub solves it once, in a base class every model inherits:
class ApiModel(BaseModel):
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
from_attributes=True,
)That is fifteen lines of configuration doing three separate jobs, and all three are worth understanding.
alias_generator=to_camel gives every field an automatic alias, so
price_per_night is pricePerNight on the wire — in both directions,
for every model, without a single per-field declaration.
populate_by_name=True accepts both spellings on input.
Without it a request must use camelCase, which makes every curl example and every test
fixture read strangely. With it, both work and the frontend is unaffected.
from_attributes=True lets a model be built from an arbitrary
object's attributes rather than a dict — which is what makes this line work:
return PropertyResponse.model_validate(PropertyService(db).get_for_public(public_id))That argument is a SQLAlchemy row, not a dictionary. model_validate reads its
attributes and builds the DTO. In v1 this was called orm_mode and needed
from_orm(); in v2 it is one config flag and the ordinary constructor path.
Separate the model you store from the model you return
This is the habit that matters most, and the reason is security rather than tidiness.
StayHub has four models for one concept. PropertyCreateRequest is what a host may
send. PropertyUpdateRequest is the same fields, all optional, because a PATCH means
"change only what is present". PropertyResponse is what a guest may see. And
Property — the SQLAlchemy model — is what is stored.
Look at what the response deliberately omits:
class PropertyResponse(ApiModel):
public_id: UUID
title: str
description: str
property_type: str
room_type: str
status: PropertyStatus
city: str
state: str | None = None
country: str
latitude: Decimal | None = None
longitude: Decimal | None = NoneThere is no address_line1, and its absence is a decision:
# `address_line1` is deliberately omitted. Airbnb shows the exact address only after booking;
# publishing it on a listing page tells the internet which houses are empty next week.The same pattern protects the host's identity. A listing includes its host, but not the whole user record:
class PropertyHostResponse(ApiModel):
"""The slice of a host a guest is allowed to see. Note the absence of `email`."""
public_id: UUID
first_name: str
avatar_url: str | None = None
host_bio: str | None = NoneIf you return the ORM object directly, every column you ever add is published the day you add
it. A response model inverts that: new columns are private until somebody deliberately exposes
them. That is the difference between a leak being an act of commission and one of omission, and it
is why response_model is a security boundary rather than a formatting convenience.
PATCH means every field optional
class PropertyUpdateRequest(ApiModel):
"""Every field optional — this is a PATCH, and `None` means "leave it alone".
That is also why status is not here: publishing is a transition with rules, not a field
assignment. It gets its own endpoint.
"""The second half of that docstring is the more interesting rule. status is a real
column, and it is not in the update model, because moving a listing from draft to published has
preconditions — it must be complete, and it has to be indexed for search. Expressing that as
an assignable field would let a client skip all of it. It gets POST /publish instead.
Lesson 7 has more on actions that are not field assignments.
Computed fields
Some values are derived and should never be stored. StayHub's booking response has two:
@computed_field
@builtin_property
def cancellation_deadline(self) -> date:
return cancellation_deadline(self.check_in)
@computed_field
@builtin_property
def is_cancellable(self) -> bool:
return is_cancellable(self.status, self.check_in)Both answers change at every midnight, so a stored copy is wrong within a day of being written.
@computed_field adds them to the output without making them inputs: they
appear in the JSON and in the OpenAPI schema, and nothing can send them. The alternative —
passing the values in at construction — meant every call site had to remember to, and this way
the rule travels with the DTO.
The frontend uses isCancellable to decide whether to render a Cancel button. The
server checks the same rule again when a cancellation actually arrives, because a hidden button is
a courtesy, not a permission.
The trap in that snippet
Notice it says @builtin_property, not @property. That is not a style
choice:
# ⚠️ `BookingResponse` has a field called `property`, and a class body is one namespace: the
# moment that annotation is assigned, the name `property` inside the class refers to the field,
# not to the builtin decorator. A `@property` written after it fails with
# "TypeError: 'NoneType' object is not callable" — which points at the decorator line and says
# nothing about the field twenty lines above that caused it.
builtin_property = propertyA class body is an ordinary namespace evaluated top to bottom. property:
BookingPropertyResponse | None = None binds the name property to a field, and
every later reference in that body resolves to it rather than to the builtin. Capturing the builtin
under another name at module level sidesteps it without renaming a field that the domain genuinely
calls "property".
The general lesson is worth more than the specific one: pydantic field names share a namespace
with anything else in the class body. id, type, list,
property and copy are all real domain words and all shadow something.
Two more sharp edges
EmailStr rejects reserved test domains. StayHub's fixtures use
@stayhub.test, and the validator refuses them with "the part after the @-sign is a
special-use or reserved name". It is right — RFC 6761 reserves .test precisely so
it can never be a real domain, which is exactly why it is the correct TLD for demo accounts and
exactly why mail can never be delivered there:
email_validator.TEST_ENVIRONMENT = TrueThat flag says "these addresses are for testing, allow the reserved TLDs". A production service
should not set it: there, an address at .test really is a mistake worth
catching. Also note EmailStr needs the pydantic[email] extra, and without
it every model using the type fails at import time — so the whole app refuses
to start rather than failing on one request. That is the better failure, but only if you know to
look at the install.
Mutable defaults are safe here, unlike in plain Python.
amenity_slugs: list[str] = [] would be a shared-mutable-default bug in an ordinary
function. Pydantic deep-copies defaults per instance, so each model gets its own list. You do not
need Field(default_factory=list) for this — though it is still the right tool
when the default has to be computed, like a timestamp or a UUID.
Reusable constrained types
Once the same constraint appears in three models, name it. Annotated makes a
constrained type a first-class thing you can import:
from decimal import Decimal
from typing import Annotated
from pydantic import Field
Money = Annotated[Decimal, Field(gt=0, le=Decimal("100000"), decimal_places=2)]
ShortText = Annotated[str, Field(min_length=1, max_length=120)]
Guests = Annotated[int, Field(ge=1, le=50)]
class Listing(BaseModel):
city: ShortText
price_per_night: Money
max_guests: GuestsThat reads better than the repeated Field(...) calls and, more usefully, gives the
rule one definition. When somebody decides the ceiling should be 200,000, it is one edit rather
than a search. The same aliases work as parameter annotations, so a query parameter and a body
field can share a constraint.
Inheritance covers the other kind of reuse — whole models that extend one another. StayHub uses it to say something precise:
class BookingCreateRequest(QuoteRequest):A booking request is a quote request, with no fields added. That is not laziness — it is the guarantee that you cannot be quoted one thing and charged for another, expressed as a type. The two endpoints run the same pricing code because they accept the same input.
The config options worth knowing
| Option | Does |
|---|---|
alias_generator | renames every field on the wire — the camelCase boundary |
populate_by_name | accept the Python name on input as well as the alias |
from_attributes | build from an object's attributes, e.g. a SQLAlchemy row |
extra="forbid" | reject unknown fields instead of ignoring them |
frozen=True | make instances immutable and hashable |
str_strip_whitespace | trim every string on the way in |
validate_assignment | re-run validators when a field is set after construction |
strict | turn off type coercion entirely |
extra="forbid" deserves a moment, because the default is the opposite. By default an
unknown field is silently dropped — which is what makes it safe to leave total
off a booking request, as the last lesson showed. Turning it on instead means a typo like
{"guest": 2} for guests becomes a 422 rather than a silently-defaulted
value. Both are defensible: ignoring is friendlier to clients and to versioning, forbidding catches
mistakes sooner. Pick per model, and prefer forbidding on internal APIs where both ends ship
together.
Controlling what comes out
Serialisation has its own switches, and the defaults are not always what you want.
booking.model_dump()
# {'public_id': UUID('...'), 'check_in': date(2027, 3, 1), 'nightly_rate': Decimal('189.00')}
booking.model_dump(mode="json")
# {'public_id': '...', 'check_in': '2027-03-01', 'nightly_rate': '189.00'}
booking.model_dump(by_alias=True)
# {'publicId': ..., 'checkIn': ..., 'nightlyRate': ...}
booking.model_dump(exclude_none=True)
# omits every field that is currently Nonemodel_dump() gives Python objects; mode="json" gives JSON-safe ones.
Forgetting that is the usual cause of "Object of type UUID is not JSON serializable" when writing
to a queue or a cache by hand. FastAPI does the right thing automatically on a response, so this
only bites off the response path.
by_alias=True is not the default, which surprises people who set up
an alias generator. Inside your own code the snake_case names are what you want; the camelCase form
is for the wire, and FastAPI applies it for you when serialising a response.
Two per-route switches are worth knowing. response_model_exclude_none=True drops
nulls from the payload, which can shrink a list response substantially. And
response_model_exclude={"host"} removes a field for one endpoint without needing a
second model — useful, but reach for a real model once you are excluding more than one thing,
because an exclusion list is invisible from the model itself.
Validation is not authorization
Worth stating plainly, because the two get conflated. Pydantic answers "is this well formed?" It cannot answer "is this person allowed?" or "is this true right now?"
StayHub's booking request validates that check-out is after check-in. It cannot validate that the dates are free, that the guest is not the host, or that the listing is published — those need the database, and they live in the service layer:
def _validate_stay(self, prop: Property, check_in: date, check_out: date, guests: int) -> None:
today = datetime.now(UTC).date()
if check_in < today:
raise ApiException("Check-in cannot be in the past.")
if check_out <= check_in:
raise ApiException("Check-out must be after check-in.")
if guests > prop.max_guests:
raise ApiException(f"This place sleeps up to {prop.max_guests} guests.")The check-out rule appears in both places, and that duplication is deliberate rather than sloppy: the model rejects it earlier and more cheaply, and the service is the rule that holds no matter who calls it. A schema is a filter on shape; a service is the authority on meaning.
Using pydantic away from the request
Models are not only for endpoints, and two uses pay for themselves immediately.
Validating something that is not a model. TypeAdapter applies the
same machinery to any type — a bare list, a dict, a union — which is what you want when
parsing a config file or a third-party payload:
from pydantic import TypeAdapter
adapter = TypeAdapter(list[PropertyImageInput])
images = adapter.validate_python(raw_from_somewhere) # a real list[PropertyImageInput]Typed settings. pydantic-settings reads environment variables
through the same validation, so a misspelt variable is a startup error rather than a
None that surfaces three layers deep at request time. StayHub's whole configuration is
one model, and lesson 4 covers it properly.
The mistakes worth not making
- Returning the ORM object instead of a response model. Every column you add later is published the day you add it.
floatfor money. UseDecimal, all the way to the database column.- Unbounded strings and numbers. Every
strwants amax_length; everyintwants a range. str | Nonewithout a default, when you meant optional.- Reaching for a validator when a constraint would do.
ge=1is in the OpenAPI schema; a hand-written check is not. - Treating validation as authorization. A schema cannot know who is asking.
Next: project structure — where the models, services and repositories in this lesson actually live, and the rules that keep them from collapsing into each other.