FastAPI – Pydantic Models and Validation

September 8, 202514 min readUpdated 8/23/2026

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 null

c 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 part

42.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 = False

Declared 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 self

mode="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 = None

There 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 = None

If 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 = property

A 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 = True

That 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: Guests

That 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

OptionDoes
alias_generatorrenames every field on the wire — the camelCase boundary
populate_by_nameaccept the Python name on input as well as the alias
from_attributesbuild from an object's attributes, e.g. a SQLAlchemy row
extra="forbid"reject unknown fields instead of ignoring them
frozen=Truemake instances immutable and hashable
str_strip_whitespacetrim every string on the way in
validate_assignmentre-run validators when a field is set after construction
strictturn 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 None

model_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.
  • float for money. Use Decimal, all the way to the database column.
  • Unbounded strings and numbers. Every str wants a max_length; every int wants a range.
  • str | None without a default, when you meant optional.
  • Reaching for a validator when a constraint would do. ge=1 is 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.