Advanced Pydantic Validation and Serialization

Every FastAPI request crosses a line where bytes from an untrusted network become typed Python objects, and every response crosses it back. Pydantic owns that line. How you write your models decides what a client sees when it sends something wrong, what your OpenAPI document claims, how much CPU a response costs, and how much of your business logic ends up in the wrong layer.

This is the data half of the site. Core Architecture and Routing Patterns decides how a request reaches a handler; this area decides what shape it is in when it arrives and what leaves. Async, Background Tasks and Observability picks up the runtime concerns underneath both. The home page has the full map.

Seven guides sit beneath this one. Request Validation Patterns is where to start if you are debugging a specific 422, because it covers how FastAPI decides which part of a request each parameter comes from. Custom Validators and Field Constraints covers expressing rules. Nested Model Serialization covers the return trip. JSON Schema Customization covers the document generated from all of it. Performance Optimization for Pydantic Models covers doing less work per request. Type Hinting and IDE Integration covers the annotations that drive the whole machine. And Pydantic V2 Migration Guide covers getting there from v1, which a large share of production codebases still need.

Three places a validation rule can live A four-column table. A Field constraint sees one coerced field, appears in the generated JSON Schema, and reports errors against that field with a specific error type. A field_validator sees one field, does not appear in JSON Schema, and reports a generic value_error against that field. A model_validator sees every field, does not appear in JSON Schema, and reports a value_error against the whole body rather than any single field. Rule expressed as Sees In JSON Schema 422 loc path Field(gt=0) constraint one field yes body · field @field_validator one field no body · field @model_validator every field no body (whole) Reach for the highest row your rule fits in
The three rows are not interchangeable. Only the first documents itself, and only the third can compare fields — but the third pays for it by losing the field-level error location clients rely on.

That table is the single most useful thing to internalise about this area, and the rest of this page is largely an argument for it. The following section demonstrates each row with real output.

Where a rule belongs

Pydantic offers several places to put the same check, and they are not equivalent. A constraint declared on Field is compiled into the validation core and emitted into the JSON Schema, so it enforces itself and documents itself. A field_validator runs arbitrary Python against one field. A model_validator runs after the fields are populated and can therefore compare them.

The following model expresses one rule in each of the three ways, so the differences show up in the responses rather than in an assertion.

"""The same business rule expressed three ways, and how differently each one fails."""
from typing import Annotated

from fastapi import FastAPI
from pydantic import BaseModel, Field, field_validator, model_validator

app = FastAPI()


class Booking(BaseModel):
    # 1. A declarative constraint. Enforced by pydantic-core; appears in JSON Schema.
    seats: Annotated[int, Field(gt=0)]

    # 2. A field validator. Arbitrary Python, one field, invisible to JSON Schema.
    tier: str

    # 3. A model validator. Sees every field, so it can express cross-field rules.
    row: int
    seats_per_row: int

    @field_validator("tier")
    @classmethod
    def known_tier(cls, value: str) -> str:
        if value not in {"economy", "business"}:
            raise ValueError("tier must be economy or business")
        return value

    @model_validator(mode="after")
    def seats_fit_the_row(self) -> "Booking":
        if self.seats > self.seats_per_row:
            raise ValueError("cannot book more seats than the row holds")
        return self


@app.post("/bookings/")
async def create_booking(booking: Booking) -> dict[str, str]:
    return {"status": "confirmed", "tier": booking.tier}

Four requests were sent through the verification harness — one valid, then one violating each rule. This is its recorded output, unedited:

$ POST /bookings/  {"seats": 2, "tier": "business", "row": 4, "seats_per_row": 6}
200 OK
{
  "status": "confirmed",
  "tier": "business"
}

$ POST /bookings/  {"seats": 0, "tier": "business", "row": 4, "seats_per_row": 6}
422 Unprocessable Entity
{
  "detail": [
    {
      "type": "greater_than",
      "loc": [
        "body",
        "seats"
      ],
      "msg": "Input should be greater than 0",
      "input": 0,
      "ctx": {
        "gt": 0
      }
    }
  ]
}

$ POST /bookings/  {"seats": 2, "tier": "first", "row": 4, "seats_per_row": 6}
422 Unprocessable Entity
{
  "detail": [
    {
      "type": "value_error",
      "loc": [
        "body",
        "tier"
      ],
      "msg": "Value error, tier must be economy or business",
      "input": "first",
      "ctx": {
        "error": {}
      }
    }
  ]
}

$ POST /bookings/  {"seats": 5, "tier": "economy", "row": 4, "seats_per_row": 3}
422 Unprocessable Entity
{
  "detail": [
    {
      "type": "value_error",
      "loc": [
        "body"
      ],
      "msg": "Value error, cannot book more seats than the row holds",
      "input": {
        "seats": 5,
        "tier": "economy",
        "row": 4,
        "seats_per_row": 3
      },
      "ctx": {
        "error": {}
      }
    }
  ]
}

Compare the three failures. The Field constraint produced "type": "greater_than" with a ctx naming the bound — a client can render "must be more than 0" in any language without parsing English. The field_validator produced "type": "value_error", which tells a client only that something was wrong with tier; the specific reason is embedded in a message string prefixed with Value error,. And the model_validator produced "loc": ["body"] with no field name at all, plus the entire submitted body echoed back into input.

That last one has two practical consequences. A form UI cannot highlight the offending input, because the error does not name a field — you have to attach it to the form as a whole or map it manually. And the echoed input is the whole payload, which for a login or payment model means credentials or card data land in whatever consumes that response.

None of this argues against model validators; cross-field rules have to live somewhere. It argues for reaching for the highest row in the diagram that your rule fits into, and for knowing what you gave up when you could not. Custom Validators and Field Constraints covers the full ordering of the pipeline; Cross-Field Validation Patterns in Pydantic v2 covers making the third row as informative as it can be. Before, After and Wrap Validators in Pydantic v2 covers running before coercion instead of after, and Creating Reusable Custom Validators in Pydantic covers packaging a rule as a reusable annotated type instead of copying it between models.

Why this matters at scale. Validation errors are an API contract that most teams never design, they just emit whatever falls out. Once several clients exist, changing an error shape breaks integrations exactly as hard as changing a response model does — so it is worth choosing the shape deliberately while you still can.

One declaration, three artefacts

A model is not only a validator. FastAPI reads the same declaration to produce the error response, the serialized success response, and the JSON Schema in your OpenAPI document. Seeing all three come out of one class is what makes the "document it by declaring it" argument concrete.

"""One model declaration, three artefacts: a 422 body, a response body, and JSON Schema."""
from typing import Annotated

from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI()


class Shipment(BaseModel):
    tracking_code: Annotated[str, Field(min_length=8, description="Carrier tracking code.")]
    weight_grams: Annotated[int, Field(gt=0, le=30_000)]
    fragile: bool = False


@app.post("/shipments/")
async def create_shipment(shipment: Shipment) -> Shipment:
    return shipment


@app.get("/schema")
async def schema() -> dict:
    """The real generated schema for the model above, read back out of the OpenAPI document."""
    return app.openapi()["components"]["schemas"]["Shipment"]

The /schema endpoint is not a mock-up — it returns whatever FastAPI actually generated at startup. Recorded output:

$ POST /shipments/  {"tracking_code": "ZX9911QT", "weight_grams": 1450}
200 OK
{
  "tracking_code": "ZX9911QT",
  "weight_grams": 1450,
  "fragile": false
}

$ POST /shipments/  {"tracking_code": "short", "weight_grams": 90000}
422 Unprocessable Entity
{
  "detail": [
    {
      "type": "string_too_short",
      "loc": [
        "body",
        "tracking_code"
      ],
      "msg": "String should have at least 8 characters",
      "input": "short",
      "ctx": {
        "min_length": 8
      }
    },
    {
      "type": "less_than_equal",
      "loc": [
        "body",
        "weight_grams"
      ],
      "msg": "Input should be less than or equal to 30000",
      "input": 90000,
      "ctx": {
        "le": 30000
      }
    }
  ]
}
$ GET /schema
200 OK
{
  "properties": {
    "tracking_code": {
      "type": "string",
      "minLength": 8,
      "title": "Tracking Code",
      "description": "Carrier tracking code."
    },
    "weight_grams": {
      "type": "integer",
      "maximum": 30000.0,
      "exclusiveMinimum": 0.0,
      "title": "Weight Grams"
    },
    "fragile": {
      "type": "boolean",
      "title": "Fragile",
      "default": false
    }
  },
  "type": "object",
  "required": [
    "tracking_code",
    "weight_grams"
  ],
  "title": "Shipment"
}

Three things in the schema are worth noticing because they are all consequences of declarations, not of anything written separately. min_length=8 became minLength: 8, so a client generator can enforce it before a request is ever sent. The description moved straight through into the property. And fragile, which has a default, is absent from required — the schema knows the field is optional because the Python declaration says so.

Notice also that the failing request reported both problems in one response rather than stopping at the first. That is worth designing your error handling around: clients can fix everything in one round trip if you pass the full list through, which is precisely what a hand-rolled handler that reads detail[0] throws away.

JSON Schema Customization covers steering this generation, with Examples in the OpenAPI Schema with FastAPI on attaching realistic sample payloads, Discriminated Unions in OpenAPI with Pydantic on the polymorphic case that generators otherwise handle badly, and Customizing OpenAPI Schema Generation in FastAPI on the document-level controls.

Why this matters at scale. A separately maintained API document is wrong within a quarter. One generated from the code can only be wrong in the ways the code is wrong, which is a much smaller and much more discoverable set of failures.

Reading the request, not just the body

Before any model validates anything, FastAPI has to decide where each parameter comes from — path, query string, header, cookie, body, or form. It infers this from the type and the declaration, using rules that are entirely reasonable and entirely non-obvious, and the resulting mistakes look like validation bugs. A bool parameter declared next to a body model, for instance, does not become a body field.

Request Validation Patterns is the guide for this layer and the newest in the area. It covers the source-inference rules, the anatomy of the 422 in enough detail to write client code against, and why you should assert on type and loc in tests rather than on msg, which is not a stable interface.

Three specific cases have their own guides. Query, Path and Body Parameter Validation in FastAPI covers constraining each source and the aliasing that lets an external contract differ from your Python names. Validating File Uploads and Forms in FastAPI covers multipart, which behaves differently enough from JSON to surprise people. And Optional vs Nullable Fields in Pydantic and FastAPI covers the distinction that causes more PATCH bugs than any other: a field the client omitted and a field the client explicitly set to null are different intentions, and only one of them means "clear this value".

Why this matters at scale. The source-inference rules are applied when the route is built, not when the request arrives, so getting them wrong produces an endpoint that has always been broken in a way tests written against the same misunderstanding will not catch. Reading the generated schema is the fastest way to confirm FastAPI agreed with you.

The return trip

Serialization gets less attention than validation and is frequently the more expensive half. A response model walks the entire object graph, and the cost tracks the total number of nodes rather than the number of top-level fields — a list of two hundred orders each holding a handful of line items is a large number of nodes.

Nested Model Serialization covers composition and the controls over what appears in output. Handling Deeply Nested JSON Models Efficiently covers keeping that cost down, Excluding Fields Per Endpoint in FastAPI covers serving different shapes of one entity from different routes without maintaining parallel models, and Self-Referencing and Recursive Models in Pydantic v2 covers trees and comment threads, where the schema has to refer to itself.

Performance work in this area is mostly the removal of duplicated effort rather than clever optimisation. Performance Optimization for Pydantic Models sets out the approach; Pydantic Model Serialization Performance in FastAPI measures the output path specifically. Two escape hatches get their own treatment: TypeAdapter for Non-Model Types in Pydantic for validating a list[Something] or a dict without wrapping it in a model, and model_construct and When to Skip Validation in Pydantic for the narrow case where you genuinely have already-validated data and re-checking it is pure waste.

Why this matters at scale. Serialization runs on the same thread as your handler, so an expensive response is not just slow for the client that asked for it. That connection is developed in Async Correctness and Concurrency, and it is the reason a heavy list endpoint can degrade an entire worker.

Annotations as machinery

Everything above is driven by type hints, which in FastAPI are not documentation — they are the program. The framework reads them at import time to build the validators, decide parameter sources, resolve the dependency graph and generate the schema. Type Hinting and IDE Integration covers treating them accordingly, including the practical consequence that your type checker is a test suite you already own.

Annotated is the piece that makes this composable, letting one alias carry a type together with its constraints, its metadata, or an entire dependency. Annotated Dependencies and Reusable Types in FastAPI covers building a small vocabulary of these for a codebase — a PositiveIntId, a CurrentUser — so that a rule is declared once and referenced by name everywhere it applies.

Why this matters at scale. A codebase where constraints are inlined at each use site has no single place to change them. One where they are named types has exactly one, and every use site gains the change for free.

Getting to v2

Pydantic v2 replaced the validation core, and with it the API: validators, configuration and serialization all have new names, and coercion is stricter in ways that quietly change behaviour rather than raising. Pydantic V2 Migration Guide sets out a sequence that does not require one enormous commit, and Migrating from Pydantic v1 to v2 Without Breaking APIs covers holding the external contract steady while the internals change.

The individual renames each have a guide, because each has a wrinkle that a blanket find-and-replace gets wrong: Migrate @validator to @field_validator in Pydantic v2, Migrating @root_validator to @model_validator in Pydantic v2, model_config vs class Config in Pydantic v2, and Replacing json_encoders with field_serializer in Pydantic v2 — the last of which is the one most likely to change bytes on the wire without changing a test.

One migration question comes up often enough to answer here: async validators. v2 does not support them, and the workarounds people reach for tend to be worse than the problem. Pydantic v2 Async Custom Validator: What to Do Instead covers the alternatives, all of which amount to moving the rule out of validation and into the layer that legitimately has a database session.

Why this matters at scale. A partially migrated codebase runs two validation engines with two error formats, which means your API emits two error contracts depending on which endpoint the client hit. That is worse than either version alone, so migration is best done as a sequence of complete modules rather than a sweep of partial edits.

Cross-cutting trade-offs

DecisionSimpler formScales better asWhat it costs
Rule placementWhatever validator is handyHighest tier the rule fitsSometimes rewriting a rule to fit a constraint
Error shapeWhatever Pydantic emitsA deliberate contract, list preservedA handler to write and version
Response modelsReturn the ORM objectExplicit response model per endpointMore classes to keep in step
Field selectionOne model per shapeOne model plus per-endpoint exclusionExclusion rules to keep readable
Schema metadataTitles inferred from namesDescriptions and examples on fieldsOngoing metadata upkeep
Repeated constraintsInline at each use siteNamed Annotated aliasesA vocabulary others must learn
Trusted-data handlingValidate everywhereValidate at the boundary onlyCare about what "trusted" means
Engine versionRemain on v1Complete migration to v2A focused, module-by-module project

The pattern in this area differs from the architectural one: the investment is almost always declarative. Moving a rule from imperative code into a declaration buys you documentation, a better error, and static checking at the same time, which is why the top row is the one worth pushing hardest on.

Named anti-patterns

The validator that does I/O. A field_validator that queries the database to check uniqueness. Root cause: it is the first place the value appears, so it feels like the right place to check it. Symptom: validation now depends on a network call it cannot await, has no session from the dependency graph, and fails in tests that never expected a database. Fix: keep the shape check in the model and the state check in the service layer, where dependency injection supplies a session.

The truncated 422. An exception handler that reads errors()[0] and reports one problem. Root cause: a single message is easier to render. Symptom: a client with four bad fields makes four round trips, discovering one error each time. Fix: map the whole list into your own shape, as in Customising Validation Error Responses in FastAPI.

Echoing input back to the caller. Passing Pydantic's input field straight through into your error response. Root cause: it is right there in errors() and looks helpful. Symptom: a failed login or payment returns the submitted password or card number in the error body, and from there into client logs. Fix: build your response from type and loc, and include input only for fields you have explicitly marked safe.

Double validation. Parsing a request into one model and then constructing a second model from its dump. Root cause: separate API and internal models that were never given a conversion path. Symptom: every request pays the validation cost twice, and the two models drift until they disagree. Fix: pass the model, or convert explicitly — Performance Optimization for Pydantic Models covers both.

Returning the database object. Using an ORM model as a response model, or returning it from a handler with no response model at all. Root cause: it works, immediately. Symptom: a column added for internal bookkeeping — a password hash, an internal flag, a soft-delete marker — appears in the public API the moment it is added to the table. Fix: an explicit response model, so adding a column is never an API change.

model_construct on untrusted input. Using model_construct to make a slow endpoint faster. Root cause: it genuinely is faster, because it skips everything. Symptom: unvalidated and uncoerced values flow into your domain, and the type annotations are now lies. Fix: reserve it for data validated earlier in the same process; see model_construct and When to Skip Validation in Pydantic.

FAQ

Should a rule be a Field constraint, a field validator, or a model validator? Use a Field constraint whenever the rule can be expressed as one, because only those appear in the generated JSON Schema. Use field_validator when one field needs arbitrary Python. Use model_validator when the rule involves more than one field, accepting that the resulting error is reported against the whole body rather than a single field.

What exactly does a 422 response mean in FastAPI? It means the request was well-formed but failed the contract your models declare. Each entry in the detail array carries a machine-readable type, a loc path whose first element names the parameter source, a human message, and the offending input. Clients should branch on type and loc, never on the message text.

Can a Pydantic validator query the database? It should not. Validators run inside pydantic-core with no access to your dependency graph, they cannot await, and making them do I/O turns a cheap boundary check into a network call on the request path. Rules that need system state belong in the service layer behind dependency injection.

Is it safe to skip validation with model_construct on hot paths? Only for data you have already validated in the same process, such as a model you are rebuilding after an internal transformation. model_construct performs no coercion and no checks, so applying it to anything that originated outside your process removes the boundary you built the model for.

How do I stop my OpenAPI document drifting from the code? Attach the documentation to the models rather than writing it separately: descriptions and examples on Field, discriminators on unions, and json_schema_extra for whole-payload samples. Because FastAPI generates the document from those models at startup, the schema cannot describe a shape the code does not have.

Do I have to migrate to Pydantic v2 all at once? No, and you should not. Migrate module by module behind a contract test suite that asserts on response shapes and 422 payloads, since the error format and the coercion rules both changed. The mechanical renames can be handled largely by codemod; the coercion differences are what need human review.

If you are debugging a live 422, start at Request Validation Patterns. If you are designing models for a new service, read Custom Validators and Field Constraints and JSON Schema Customization together, since the two decisions are really one. If your responses are slow, Nested Model Serialization and Performance Optimization for Pydantic Models are the pair to work through, and Type Hinting and IDE Integration underpins all of them.

Teams still on v1 should treat Pydantic V2 Migration Guide as a prerequisite for the rest.

The area upstream of this one is Core Architecture and Routing Patterns, which decides how a request reaches the model layer at all; the area downstream is Async, Background Tasks and Observability, where the cost of everything on this page shows up as latency.