Pydantic with FastAPI

The reason most people arrive here: request bodies, response models and documentation generated from the annotations you already wrote.

Overview

Why this is the common entry point

FastAPI is the most common way people first meet Pydantic. You write a model, use it as the type of a function argument, and FastAPI does the rest. It reads the request body, validates it with your model, and hands your function a typed object.

That is a lot of work done by one annotation. The same model also feeds the interactive documentation, because FastAPI turns it into an OpenAPI schema. And when validation fails, FastAPI sends back a 422 response that carries the Pydantic errors in a structured form. You did not write any of that code.

This article explains why this is the usual entry point, and how the pieces fit. It covers the request body and how the model becomes the contract. It then looks at the 422 response and what it contains, so you can read it and shape it. The last part is about response_model, which does two jobs at once: it filters what you return and it documents it. Once you see how the model sits in the middle of the framework, the rest of FastAPI makes more sense.

You do not need to know FastAPI well to follow this. The focus stays on the model and what the framework does with it. Every behaviour described here comes from a few lines you write, plus conventions the framework applies for you. Knowing which part comes from which side makes debugging much easier.

The examples stay small and focused.

Most people meet Pydantic through FastAPI. Often without realising they're two libraries. That is a compliment to the integration and it leaves a gap. All that looks like FastAPI magic is Pydantic doing what the previous tiers given.

FastAPI has no check layer of its own. It reads your annotations, hands the request body to a Pydantic model, converts the resulting ValidationError into a 422. Turns the made JSON Schema into your docs. That is the whole of it. Knowing where the line falls makes both easier to reason about.

Worth knowing

A parameter annotated with a model is the request body. FastAPI parses the JSON and validates it before your handler runs.
A ValidationError becomes a 422 whose detail is really just e.errors() — the shape you already know how to read.
Path and query parameters are validated by the same rules, and loc starts with path, query or body.
response_model validates and filters the output, so a field it does not declare cannot leak from a handler that returned too much.
Use separate Create, Update and Out models. One model with everything optional documents nothing.
Your constraints, descriptions and examples are the OpenAPI document, which is the interactive docs and every generated client.

Pydantic with FastAPI: Where It All Arrives

Request bodies, response models and docs, all from annotations you already wrote.

A model is the request body

Annotate a parameter with a model and FastAPI parses, validates and hands you a real object. client calls the app through ASGI.

example_01.pyPydantic
Output

Validation failures become 422

A ValidationError on a request body is turned into a 422 whose body is really just errors().

example_02.pyPydantic
Output

Path and query parameters too

The same rules apply to values from the URL. The loc tells the caller which part of the request was wrong.

example_03.pyPydantic
Output

response_model shapes what comes back

The output is validated and filtered against the model, so a field the response model does not declare cannot leak.

example_04.pyPydantic
Output

Three models for one resource

Create, update and output are different shapes. Separate models say what each endpoint actually accepts and returns.

example_05.pyPydantic
Output

The schema is the documentation

Everything you wrote — constraints, descriptions, examples — is in the OpenAPI document FastAPI serves.

example_06.pyPydantic
Output

The request body

@app.post("/modules/", status_code=201)
def create(module: ModuleIn):
    return {"created": module.title}

A param annotated with a model is the body. FastAPI reads the request, validates it with model_validate_json. Calls your handler with a real object.

By the time your code runs, module.minutes is an int. Even if the client sent "8". And each constraint has passed. There's no checking to do at the top of the handler. This Is the point.

Scalars annotated with plain types become query or path params. The same coercion applies: ?verbose=yes turns True because of the boolean vocab from the coercion module.

422, and what it contains

When check fails, the handler never runs. FastAPI returns 422 with a body whose detail is e.errors().

So all from the errors module applies right to what your API returns. loc is a path. Its first element is body, path, query or header. Telling the caller which part of the request was wrong before naming the field within it.

That is worth showing your API's consumers. A 422 from a FastAPI service is more helpful than most people realise. Clients often discard it and report "bad request".

If you want a other shape, an exception handler for RequestValidationError lets you reformat it globally. And the grouping code from the errors module drops straight in.

response_model does two things

@app.get("/users/{user_id}", response_model=UserOut)

It validates the output. This Catches a handler returning the wrong shape before a client does.

And it filters the output to the model's fields. A handler returning a dict with password_hash in it makes a response without one. UserOut doesn't declare it.

That second action is a genuine security property and worth leaning on on purpose. The pattern to internalise. A response model should list just what a caller may see. Then over-returning from a handler can't leak. Leaning instead on the handler returning just the right keys means each future edit to that handler is a chance to leak a thing.

It also fixes the schema. Without response_model the docs can't say what an endpoint returns. With it, consumers get a typed response.

Three models, not one

The instinct is one Module model all over. Resist it, for the reasons the defaults module gave.

Create takes what a caller may supply. No id. Not optional, absent. Because the server assigns it.

Update has all optional, and the handler applies model_dump(exclude_unset=True) so untouched fields stay untouched. This is the correct PATCH shape, and getting it wrong is how six columns become None.

Out declares just what may be seen, with server-assigned fields needed.

Three small classes, each honest. One model with all optional makes docs that promises nothing and a response type no client can lean on.

Dependencies

Depends composes checked values the same way:

def pagination(page: int = 1, size: int = Query(default=20, le=100)) -> Page:
    return Page(page=page, size=size)

@app.get("/modules/")
def list_modules(p: Page = Depends(pagination)):
    ...

The dependency's params are checked like any others. The constraint on size is enforced and documented. Each endpoint using it takes from both.

get_settings from the settings module is the same pattern. Being a dependency makes it overridable in tests.

Where validation stops

The line from the checkers module matters here more than anywhere.

A model checks shape and inner agreement. "Does this track exist?" is a fact about the world. It belongs in the handler or the service layer. Not least because the right response is a 404 or a 409, not a 422.

Keeping that split means your models stay testable without a DB. Your status codes stay real.

The documentation is your schema

app.openapi() is assembled from model_json_schema() for each model you used. Which means each recommendation from the schema module cashes out here:

A Field(gt=0) appears as a documented min. A checker checking the same thing appears as nothing.

A Literal turns a set of choices in the docs and a union type in a made client. A pattern turns an opaque string.

A description appears beside the field. An examples entry pre-fills the interactive request form. A first-time caller can send a working request instead of guessing.

This is the concrete payback for being exact in your annotations. It's visible to everybody who uses your API rather than only to you.

A note on this page

There's no server here — a browser tab can't listen on a port. client calls the app through ASGI. This Is the same interface uvicorn uses. Routing, check, status codes and schema making all behave just as they would in production.

The full reason, and the one action that truly differs, is on the FastAPI compiler page.

Where the boundary between the two libraries falls

It's worth being able to say which library is doing what. It changes where you look when a thing is wrong.

FastAPI decides routing, reads the request, chooses which params come from the path, the query and the body, calls your handler. Turns the result into a response. It also assembles the OpenAPI doc.

Pydantic validates and converts each one of those values, makes the errors. Makes the schema for each model that goes into the doc.

So a 422 you disagree with is a Pydantic question. A model's annotations, constraints or checkers. A field arriving from the wrong part of the request is a FastAPI question. docs that is missing a constraint is a Pydantic question. The constraint was never in the schema.

Most confusion about "FastAPI check" resolves the moment that split is clear.

Dependencies and settings

The settings model from the previous module composes on its own here:

@lru_cache
def get_settings() -> Settings:
    return Settings()

@app.get("/health")
def health(settings: Settings = Depends(get_settings)):
    return {"env": settings.env}

Cached, so the setting is read and checked once rather than per request. And overridable in tests through FastAPI's dependency replaces. This Is much cleaner than mutating the setting around a test.

The same pattern covers anything made once and used all over. A DB session factory, a client for another service, a pagination object built from checked query params.

Pydantic with FastAPI: summary

A model param is the request body, checked before your handler runs. Failures become a 422 whose detail is e.errors(), with loc naming which part of the request was wrong.

response_model validates and filters the output. Undeclared fields can't leak. A property worth leaning on on purpose rather than trusting each handler to return just the right keys.

split Create, Update and Out models per resource. Keep facts about the world in the service layer and shape in the model. And prefer constraints to checkers. Only one of them reaches the docs your callers read.

Mistakes people make with Pydantic with FastAPI

**No response_model.** The docs can't say what the endpoint returns. Nothing filters the output. A handler that starts returning an extra key starts leaking it.

One model for each direction. all optional so it can serve create, update and read at once. It docs nothing. No client can tell what is sure in a response.

Dumping the whole update model. patch.model_dump() without exclude_unset=True writes each field. Untouched columns are overwritten with None or defaults. This is the bug behind "it cleared fields I never edited".

Putting existence checks in checkers. "Does this track exist" belongs in the handler. In a model it makes check an I/O call, makes the model untestable without a DB. Returns a 422 where a 404 was correct.

checkers instead of constraints. Both reject the same values. Only the constraint appears in the docs and in made clients. Your callers see one of them.

given a 422 body is opaque. It's e.errors(), with loc naming body, path or query and then the field. A lot of clients discard it and report "bad request" when the exact problem was right there.

Instantiating settings per request. Reading and checking the setting on each call is wasted work. Cache it with lru_cache and inject it with Depends. This Is also what makes it overridable in tests.

A last habit

Open your own /docs page at times and read it as a consumer would.

It's made from your models. All this track has argued about being exact in annotations is visible there and nowhere else in your workflow. Fields with no description. A str where a Literal belongs. An endpoint with no response_model. So The response section says nothing. A needed field you meant to default.

None of that shows up in your tests. Your tests know what they're sending. It shows up for the person integrating with you, at the point where it's costly to ask you about it.

Five minutes reading your own docs catches most of it. It's the same five minutes recommended in the schema module. Just from the other end.

Where to go from here

This is the last module in the track. The one where all else cashes out.

The annotations from tier one decide what a request body accepts. The shapes from tier two. Nested models, sets, discriminated unions, closed sets. Decide how expressive your API can be about its own data. The checkers and config from tier three enforce what annotations can't say. The output and schema work from tier four decides what consumers receive and what your docs tells them.

FastAPI adds routing and an HTTP layer on top. Almost nothing else. Which means the quality of an API built this way is very largely the quality of its models.

That is a good spot to be in. Models are cheap to improve. Narrowing a str to a Literal, moving a rule from a checker to a constraint, splitting one all-optional model into three honest ones, adding a description to a field whose name isn't self-explanatory. Each is a small edit. Each is visible to everybody who calls you.

Questions people ask about Pydantic with FastAPI

How does FastAPI use Pydantic? A model type in the endpoint signature becomes the request body, validated before your function runs. FastAPI also uses it to build the OpenAPI docs.

What is a 422 response? It is FastAPI's reply when validation fails. The body is a list of errors with loc, msg and type, taken from Pydantic's own error entries.

What does response_model do? It validates and filters what your function returns. Fields not in the response model, such as a password hash, are dropped from the output.

Why use separate models for create, update and read? Each has different required fields. A create model needs everything, an update model makes fields optional, and a read model adds the id and hides secrets.

Do I need to call model_dump myself? Usually not. FastAPI serialises the returned model, and calling it yourself is mostly for background tasks or storage.

Where does validation stop? At the edge of the request. FastAPI will not check that a foreign key exists in your database, so that check still belongs in your code.

One last thing to check

Before shipping an endpoint, three questions that take a minute each.

**Does it have a response_model?** Without one the docs says nothing about the response. Nothing filters what a handler returns.

**Is the update path using exclude_unset=True?** Without it, a PATCH overwrites fields the caller never mentioned.

Would a 422 from this endpoint tell a caller what to fix? If a field is a bare str where a Literal belongs, or a rule lives in a checker that the schema can't show, the answer is no. And the caller finds out by being rejected rather than by reading.

Check yourself

0 of 4

Answer without scrolling back up.

  1. What does `response_model` do besides validating the output?

  2. What is in a FastAPI 422 response body?

  3. Why separate Create, Update and Out models?

  4. Where does 'does this track exist?' belong?

Cheat sheet

Pydantic with FastAPI

See how FastAPI turns Pydantic models into request validation, response shaping and automatic API documentation with no extra code.

PYDANTIC · vizlearn.in/pydantic/pydantic_with_fastapi.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.