FastAPI Request Validation with Pydantic v2

FastAPI validates request bodies with Pydantic v2 before your handler runs. Models, Field constraints, custom validators, the 422 response, and failure modes.

Most of the bugs I have chased in a FastAPI backend were not in the interesting code. They were at the boundary. A client sent "limit": "10" as a string instead of a number, or left out a field the handler assumed was always there. The failure then surfaced three functions deep as a confusing TypeError or, worse, a silent wrong answer.

The fix is almost never a defensive if inside the handler. It is declaring what a valid request looks like and letting Pydantic reject everything else before your code runs.

This post is for people who already write FastAPI endpoints and want to stop treating validation as an afterthought. It covers how validation actually runs with Pydantic v2, the constraints and custom validators worth reaching for, what the 422 response looks like, and the ways it bites you once real traffic shows up. I leaned on this in the Archi backend, the RAG (retrieval-augmented generation) copilot I built for CMS operations at CERN, where every query endpoint takes untrusted input straight from a browser.

Validation happens before your handler runs

The thing to internalize is where validation runs. When you type-annotate a parameter with a Pydantic model, FastAPI reads the raw request body, hands it to Pydantic, and calls your function only if the data survives. If it does not, FastAPI returns a 422, and your handler is never entered.

A request flow diagram. Raw JSON bytes go into a Pydantic model box that parses, coerces types, checks Field constraints, and runs custom validators. A green valid branch produces a typed model passed to the handler and a 200 response. A red invalid branch produces a 422 Unprocessable Content response with a detail array containing loc, type, and msg for the offending field. A caption notes the handler only ever sees data that passed every rule.

So a handler like the one below never has to check anything. The SearchRequest model declares a required query string and an optional top_k integer:

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class SearchRequest(BaseModel):
    query: str
    top_k: int = 5

@app.post("/search")
def search(req: SearchRequest):
    # req.query is a str, req.top_k is an int. Guaranteed.
    return run_retrieval(req.query, k=req.top_k)

Two example requests show how it behaves:

  • If a client posts {"query": "argocd sync", "top_k": "8"}, Pydantic coerces (converts) "8" to 8, and you get an int.
  • If it posts {"top_k": 8} with no query, the required query field is missing, and the request fails with a 422 before search runs.

That is the whole contract: the annotation is the validation.

Put constraints on the field, not in the handler body

A type alone is a weak spec. top_k: int accepts -4 and 1_000_000, and both are nonsense for a retrieval call. Pydantic’s Field lets you attach constraints to the type. The modern v2 style combines it with Annotated, so the constraint travels with the type rather than sitting in a default value:

from typing import Annotated
from pydantic import BaseModel, Field

class SearchRequest(BaseModel):
    query: Annotated[str, Field(min_length=1, max_length=500)]
    top_k: Annotated[int, Field(ge=1, le=50)] = 5
    rerank: bool = False

ge and le mean greater-or-equal and less-or-equal, and there are gt and lt too. For strings you also get min_length, max_length, and pattern (a regex the value must match). These are documented in the Pydantic fields reference. The payoff is that an empty query string or top_k: 5000 now returns a clear error naming the offending field. Every endpoint uses the same machinery, so you are not rewriting bounds checks by hand.

There is a real reason to keep these bounds tight. On a RAG endpoint, top_k decides how many chunks you pull and stuff into a context window. An unbounded value is not just wrong: it is a way for a caller to blow your token budget or your latency. The validation layer is the cheapest place to enforce that.

When a field rule is not enough: validators

Some rules cannot be expressed as a single constraint. For those, Pydantic v2 replaced v1’s @validator and @root_validator with @field_validator and @model_validator, and the distinction matters. A field validator sees one field. A model validator sees the whole object, which is what you need for rules that span fields.

In the example below, the field validator rejects timestamps in the future, and the model validator checks that start comes before end:

from pydantic import BaseModel, field_validator, model_validator

class DateRange(BaseModel):
    start: int  # unix seconds
    end: int

    @field_validator("start", "end")
    @classmethod
    def not_in_future(cls, v: int) -> int:
        import time
        if v > time.time():
            raise ValueError("timestamp is in the future")
        return v

    @model_validator(mode="after")
    def start_before_end(self):
        if self.start >= self.end:
            raise ValueError("start must be before end")
        return self

The mode="after" argument means the validator runs after the individual fields have been parsed and coerced, so self.start and self.end are already integers. There is also mode="before", which runs on the raw input before parsing. Use it sparingly, for things like normalizing a value that arrives in more than one shape. The validators documentation covers both.

Raising a plain ValueError inside a validator is enough. FastAPI catches it and folds the message into the 422 response, so you do not throw HTTP exceptions from inside a model.

What the client gets back: the 422 response

FastAPI’s validation error is a 422. Under the current HTTP spec, that status is named Unprocessable Content. Older tools still call it Unprocessable Entity; it is the same code. The body is a JSON object with a detail array, one entry per problem:

{
  "detail": [
    {
      "type": "greater_than_equal",
      "loc": ["body", "top_k"],
      "msg": "Input should be greater than or equal to 1",
      "input": -3
    }
  ]
}

In each entry, loc is the path to the field, type is a stable machine-readable code, and msg is human text. A frontend can walk loc to highlight the exact input that failed, which is far better than showing one generic “invalid request” toast. When I wire this into a React form, I key the errors by the last element of loc and render each one next to the matching field.

If you want a different response envelope, override the handler for RequestValidationError once, rather than reshaping errors per endpoint. This one returns the errors under an errors key, along with the request path:

from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi import Request

@app.exception_handler(RequestValidationError)
def on_validation_error(request: Request, exc: RequestValidationError):
    return JSONResponse(
        status_code=422,
        content={"errors": exc.errors(), "path": str(request.url.path)},
    )

Failure modes I have actually hit

Coercion is not always what you want. Pydantic v2 is stricter than v1 by default, but it still coerces in lax mode, which is what FastAPI uses for request bodies. A string "8" becomes 8. If you need a hard type match, opt into strict mode with Field(strict=True) or Strict on the annotation. Read the conversion table before you assume a type will be rejected; plenty of inputs you expect to fail quietly convert instead.

Extra fields pass silently by default. If a client sends a field your model does not declare, Pydantic ignores it. That is usually fine, but on a strict API it hides typos: a caller who sends "topk" instead of "top_k" gets the default 5 and no error. Set model_config = ConfigDict(extra="forbid") on the model, and the unexpected field becomes a 422. I turn this on for internal service-to-service endpoints, where a typo should never be swallowed.

Validators must return a value. A field_validator must return the value, and a model_validator(mode="after") must return self. Forget the return, and the field becomes None or the model comes back empty, with no error to tell you why. This is the single most common Pydantic v2 mistake I review out of pull requests.

Upgrade FastAPI and Pydantic together. FastAPI added Pydantic v2 support in release 0.100.0. If you are upgrading an older service, bump FastAPI and Pydantic in the same change. A new FastAPI on Pydantic v1, or the reverse, produces import errors and decorators that silently do nothing.

Tradeoffs and what I would do differently

Validation is not free. Pydantic v2 moved its core into a compiled Rust extension called pydantic-core, which made it dramatically faster than the pure-Python v1. For normal request bodies, the cost is noise next to a database call or an LLM round trip. Where it does show up is with very large or deeply nested payloads validated on a hot path. If you are ingesting a big batch, validate the envelope strictly and defer parsing the heavy inner items until you actually touch them.

The thing I got wrong early was over-modeling. It is tempting to encode every business rule into validators, until the model becomes a second copy of your domain logic that drifts from the first. My rule now:

  • The model enforces shape and bounds: the things that make the request well-formed.
  • The handler owns rules that need I/O: a database lookup or another service (“does this user exist”, “is this document indexed”). A validator that does I/O is a validator you cannot reason about.

Keep the boundary thin and honest, and the rest of the code gets simpler because it can trust its inputs.

If you want to see this pattern in a real service, the FastAPI backends behind Archi and my other LLM and RAG projects all start from the same place: a typed request model at every entry point. The messy validation lives in one declarative spot instead of being scattered through the handlers.

Diagram is my own, made for this post. No external image was used.