Request Validation Patterns in FastAPI
Key takeaways:
- FastAPI infers a parameter's source from its name and its type; explicit
Query,Path,Body,Header,CookieandFormmarkers override the inference. Annotatedis where the metadata belongs — it separates "what this parameter is" from "what its default value is".- Every parameter is resolved before the handler runs, and all failures are reported together in one 422.
- A validation error has five keys:
type,loc,msg,inputand, when a constraint carries parameters,ctx. - The first element of
locnames the source, which is the fastest way to diagnose "the client sent it but FastAPI says it is missing".
This guide sits under Advanced Pydantic Validation and Serialization and covers the boundary layer: everything that happens between an HTTP request arriving and your handler's first line executing. The pages beneath it go deeper on query, path and body parameter validation, on file uploads and form data, and on the optional versus nullable distinction.
Everything on this page was produced by executing the code shown. The transcripts come from _verify/examples/val-422-anatomy.py and _verify/examples/val-param-sources.py running on FastAPI 0.139.2, Pydantic 2.13.4 and Python 3.12.
Prerequisites
You should be comfortable with Pydantic v2 models and Annotated. Familiarity with dependency injection strategies helps, because dependencies are resolved by the same machinery and their parameters obey the same source rules. If you are still on Pydantic v1, the error shapes below will not match yours — start with the Pydantic V2 migration guide.
How FastAPI Assigns a Parameter to a Source
FastAPI inspects your handler's signature once, at import time, and builds a dependant — a tree describing where each argument must come from. The inference has a short set of rules, applied in order:
- Does the parameter name appear in the path template? If the route is
/users/{user_id}/ordersand the handler takesuser_id, it is a path parameter. Path parameters are always required; there is no such thing as an optional path segment in a single route. - Is there an explicit marker in the annotation?
Annotated[str, Query()],Annotated[str, Header()],Annotated[bytes, File()]and friends win over everything that follows. - Is the type a Pydantic model, a dataclass, or another "complex" type? Then it is read from the request body as JSON.
- Otherwise it is a scalar —
int,str,float,bool,UUID,datetime, anEnum, or alistof those — and it comes from the query string.
That last rule is the one that surprises people. Adding a bool flag next to a body model silently makes it a query parameter:
from typing import Annotated, Any
from fastapi import Body, FastAPI, Query
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
price: float
@app.get("/users/{user_id}/orders")
async def list_orders(user_id: int, status: str = "open", limit: int = 10) -> dict[str, Any]:
# user_id matches the path template -> path parameter.
# status and limit do not -> query parameters, because they are scalars.
return {"user_id": user_id, "status": status, "limit": limit}
@app.post("/items/")
async def create_item(item: Item, dry_run: bool = False) -> dict[str, Any]:
# item is a BaseModel -> body. dry_run is a scalar not in the path -> query string.
return {"dry_run": dry_run, "item": item.model_dump()}
Running this against the real app — the transcript below is the genuine output of verify.py val-param-sources, not a paraphrase:
$ GET /users/7/orders?status=shipped&limit=2
200 OK
{
"user_id": 7,
"status": "shipped",
"limit": 2
}
$ GET /users/seven/orders
422 Unprocessable Entity
{
"detail": [
{
"type": "int_parsing",
"loc": [
"path",
"user_id"
],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"input": "seven"
}
]
}
$ POST /items/?dry_run=true {"name": "Widget", "price": 9.5}
200 OK
{
"dry_run": true,
"item": {
"name": "Widget",
"price": 9.5
}
}
dry_run had to be sent in the query string. A client that put "dry_run": true inside the JSON object would have got a 200 with dry_run still false, because the extra key is simply ignored by Item under Pydantic's default extra="ignore". Silent, and expensive to debug — which is why the "scalars go to the query string" rule is worth memorizing rather than rediscovering.
Overriding the inference
Three markers change the default:
Body()moves a scalar into the JSON body.Query()on a Pydantic model (FastAPI 0.115 and later) reads the model's fields from the query string.Body(embed=True)nests a single model under its parameter name instead of making the model be the body.
class Filters(BaseModel):
"""A Pydantic model explicitly bound to the query string (FastAPI 0.115+)."""
status: str = "any"
limit: int = 10
@app.post("/items/embedded")
async def create_embedded(item: Annotated[Item, Body(embed=True)]) -> dict[str, Any]:
# embed=True nests the model under its parameter name in the body.
return {"item": item.model_dump()}
@app.get("/search")
async def search(filters: Annotated[Filters, Query()]) -> dict[str, Any]:
# Explicitly bound to the query string, so a model can describe many query params at once.
return filters.model_dump()
Real output for both:
$ POST /items/embedded {"name": "Widget", "price": 9.5}
422 Unprocessable Entity
{
"detail": [
{
"type": "missing",
"loc": [
"body",
"item"
],
"msg": "Field required",
"input": null
}
]
}
$ POST /items/embedded {"item": {"name": "Widget", "price": 9.5}}
200 OK
{
"item": {
"name": "Widget",
"price": 9.5
}
}
$ GET /search?status=open&limit=3
200 OK
{
"status": "open",
"limit": 3
}
$ GET /search?limit=notanint
422 Unprocessable Entity
{
"detail": [
{
"type": "int_parsing",
"loc": [
"query",
"limit"
],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"input": "notanint"
}
]
}
Note the loc on the failing /search request: ["query", "limit"], not ["body", "filters", "limit"]. Binding a model to the query string does not nest it. That makes Query()-bound models a clean way to share a filter contract between endpoints without every handler repeating six keyword arguments.
The Role of Annotated
There are two ways to attach metadata to a parameter. The older one puts it in the default slot:
# Legacy style: the marker occupies the default value.
async def search(q: str = Query(min_length=2, default="all")): ...
The modern one puts it in the type:
# Annotated style: the metadata is part of the type, the default is a real default.
async def search(q: Annotated[str, Query(min_length=2)] = "all"): ...
These are not merely stylistic alternatives. Four practical differences:
The function stays callable. With the legacy form, calling search() directly in a unit test binds q to a Query object, not to "all". With Annotated, q really is "all", so the handler is an ordinary function you can call without a client.
Type checkers see the truth. q: str = Query(...) declares a str whose default is not a str. Static analysers either special-case FastAPI or complain. Annotated[str, Query(...)] = "all" is internally consistent.
The metadata is reusable. Annotated types can be aliased and shared, which is the whole subject of Annotated dependencies and reusable types. A Query in a default slot is stuck on that one parameter.
It composes with Pydantic's own metadata. Annotated[int, Field(ge=1), SomeAfterValidator] stacks cleanly, which is how the reusable custom validators approach works.
The default-value form still works and is not deprecated. New code should use Annotated anyway; it is the form FastAPI's own documentation now leads with, and it is the only form that survives being refactored into a shared type.
Validation Order
The sequence for a single request is fixed:
- Routing. Starlette matches the path. A failure here is a 404, never a 422 — the request never reaches parameter validation.
- Dependency resolution. Dependencies are resolved depth-first. Their own parameters are validated by the same rules, so a
Query(min_length=2)inside a dependency produces a["query", …]error exactly as it would in the handler. - Parameter resolution. Path, query, header and cookie values are read as strings from the request and coerced to their declared types.
- Body parsing. The body is read and JSON-decoded (or form-decoded). A syntactically invalid JSON body fails here with a
json_invaliderror, and no field-level errors follow, because there is nothing to walk. - Accumulation. All errors from steps 2–4 are collected into one list.
- Dispatch. If the list is empty, the handler is called. If not, FastAPI raises
RequestValidationErrorand its default handler returns 422 with the list asdetail.
Two consequences are worth internalising. First, a handler never runs with partially valid arguments — there is no "it got past validation but the field was wrong" state. Second, errors from different sources arrive together. One request can fail its path, query and body simultaneously and get a single response describing all three:
@app.post("/orders/{tenant}/audit")
async def audited_order(
tenant: Annotated[str, Path(min_length=2)],
trace: Annotated[str, Query(min_length=4)],
order: Order,
reason: Annotated[str, Body(min_length=5)],
) -> dict[str, Any]:
return {"tenant": tenant, "trace": trace, "reason": reason}
$ POST /orders/t/audit?trace=xy {"order": {"customer_id": 1, "items": []}, "reason": "oops"}
422 Unprocessable Entity
{
"detail": [
{
"type": "string_too_short",
"loc": [
"path",
"tenant"
],
"msg": "String should have at least 2 characters",
"input": "t",
"ctx": {
"min_length": 2
}
},
{
"type": "string_too_short",
"loc": [
"query",
"trace"
],
"msg": "String should have at least 4 characters",
"input": "xy",
"ctx": {
"min_length": 4
}
},
{
"type": "string_too_short",
"loc": [
"body",
"reason"
],
"msg": "String should have at least 5 characters",
"input": "oops",
"ctx": {
"min_length": 5
}
}
]
}
What does not happen at this stage is response validation. A response_model mismatch is caught after the handler returns and surfaces as a 500, because a response your own code produced that violates your own contract is a server bug, not a client one. That asymmetry is covered under response model and serialization order.
Anatomy of the 422
Every entry in detail has the same shape. Here is one endpoint declaring all four non-body sources at once, and what a request that violates every one of them returns:
@app.get("/loc-demo/{item_id}")
async def loc_demo(
item_id: Annotated[int, Path(ge=1)],
q: Annotated[str, Query(min_length=3)],
x_api_key: Annotated[str, Header()],
session: Annotated[str, Cookie()],
) -> dict[str, Any]:
return {"item_id": item_id, "q": q, "x_api_key": x_api_key, "session": session}
$ GET /loc-demo/abc?q=a
422 Unprocessable Entity
{
"detail": [
{
"type": "int_parsing",
"loc": [
"path",
"item_id"
],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"input": "abc"
},
{
"type": "string_too_short",
"loc": [
"query",
"q"
],
"msg": "String should have at least 3 characters",
"input": "a",
"ctx": {
"min_length": 3
}
},
{
"type": "missing",
"loc": [
"header",
"x-api-key"
],
"msg": "Field required",
"input": null
},
{
"type": "missing",
"loc": [
"cookie",
"session"
],
"msg": "Field required",
"input": null
}
]
}
That single response carries a real example of the path, query, header and cookie prefixes. The body prefix needs a payload, so here is the fifth, with nesting:
class LineItem(BaseModel):
sku: str = Field(min_length=3)
quantity: int = Field(gt=0)
class Order(BaseModel):
customer_id: int
items: list[LineItem]
$ POST /orders/ {"customer_id": "n/a", "items": [{"sku": "ab", "quantity": 0}]}
422 Unprocessable Entity
{
"detail": [
{
"type": "int_parsing",
"loc": [
"body",
"customer_id"
],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"input": "n/a"
},
{
"type": "string_too_short",
"loc": [
"body",
"items",
0,
"sku"
],
"msg": "String should have at least 3 characters",
"input": "ab",
"ctx": {
"min_length": 3
}
},
{
"type": "greater_than",
"loc": [
"body",
"items",
0,
"quantity"
],
"msg": "Input should be greater than 0",
"input": 0,
"ctx": {
"gt": 0
}
}
]
}
The five keys
| Key | What it is | Use it for |
|---|---|---|
type | Stable machine identifier for the failure: missing, int_parsing, string_too_short, greater_than, literal_error, string_pattern_mismatch, … | Branching in client code; mapping to your own error codes |
loc | Path to the offending value: source prefix, then keys and list indices | Highlighting the right form field, including inside arrays |
msg | Human-readable English sentence generated by Pydantic | Showing to developers; not for parsing |
input | The value that was rejected, exactly as received | Debugging and log correlation |
ctx | The constraint's own parameters, present only when the constraint has any | Rendering your own message: "must be at least {min_length} characters" |
Two details are easy to miss. Header names are normalised: the Python parameter x_api_key reports as "x-api-key", because that is the wire name FastAPI derived by replacing underscores with hyphens. If your client is genuinely sending X_Api_Key, that mismatch is the bug. And input on a missing error is null for scalars but the whole enclosing object for body fields — compare the two missing entries above with the ones in the optional versus nullable guide, where a missing body field reports "input": {}.
loc elements are a mix of strings and integers. ["body", "items", 0, "sku"] means the sku field of the first element of the items array. Client code that renders these must handle both types; joining them with "." unconditionally will produce body.items.0.sku, which is fine for a log line and wrong for a JSON Pointer.
What Happens After Validation Fails
The 422 is not special-cased anywhere in the routing layer. When the accumulated error list is non-empty, FastAPI raises RequestValidationError, which carries the list and the raw body it was reading. That exception is routed to a registered exception handler exactly like any other, and FastAPI installs a default one that returns JSONResponse(status_code=422, content={"detail": exc.errors()}).
Three consequences follow from that design, and they are what make the 422 tractable in production.
You can replace the envelope without touching a single route. Registering your own handler for RequestValidationError reshapes every validation failure in the application at once. Most teams do this to wrap errors in whatever envelope the rest of their API uses, and to add a correlation id. Keep type and loc in your output — they are the only fields a client can act on programmatically — and feel free to drop input, which can contain data you would rather not echo. The mechanics are in customising validation error responses.
You can log validation failures centrally. A single handler is the natural place to emit a structured log line per rejected request, with the route, the error types and the loc paths. Aggregated, that stream tells you which client is sending malformed data and which of your constraints are too strict, which is far more actionable than a 4xx rate. Pair it with the request id from structured JSON logging with request ids.
Beware of echoing input verbatim. For a body error, input can be the entire enclosing object — which may include a password or a token the client sent to the wrong endpoint. If your logs or your error responses are less trusted than your request bodies, strip or redact it in the handler.
One more distinction worth holding onto: 422 means the request was well-formed but did not satisfy the contract. A malformed JSON body is also a 422 in FastAPI (with a single json_invalid error), while a HTTPException(400) you raise yourself means "the request satisfied the schema but is wrong for reasons the schema cannot express" — a duplicate email, an expired coupon. Keeping that boundary clean is what lets clients distinguish "fix your serializer" from "fix your data", and it is the same boundary argued for in HTTPException vs custom exception classes.
Validation Inside Dependencies
A dependency's own parameters are validated by the same machinery, which is easy to forget because the failure surfaces on the route rather than in the dependency. A Header() parameter inside an auth dependency that is missing produces a ["header", …] entry in the route's 422, indistinguishable in shape from one declared on the handler itself.
That is usually what you want: pagination, filtering and tenancy headers can be declared once in a dependency and reused across dozens of routes, and their constraints appear in each route's OpenAPI document automatically. It has one trap. A dependency that raises HTTPException(401) short-circuits the request immediately, whereas a dependency whose parameters fail validation contributes to the accumulated 422 instead. So a request that is both unauthenticated and malformed can return either 401 or 422 depending on whether the auth check runs before or after the failing parameter is resolved — which is an implementation detail you should not depend on. If your clients need deterministic ordering, do the authentication check in middleware, where it definitively precedes parameter validation; the trade-offs are in middleware vs dependencies.
Testing Request Validation
Validation contracts deserve tests, because they are the part of your API clients actually collide with. Two patterns cover most of it.
Assert on type and loc, never on msg. Pydantic's English strings change between minor releases; the type identifiers are stable. A test that asserts msg == "String should have at least 3 characters" is a test that breaks on upgrade for no reason.
from fastapi.testclient import TestClient
def test_short_query_is_rejected():
client = TestClient(app)
response = client.get("/loc-demo/1", params={"q": "a"})
assert response.status_code == 422
errors = {(e["type"], tuple(e["loc"])) for e in response.json()["detail"]}
assert ("string_too_short", ("query", "q")) in errors
Drive dependencies out of the way with dependency_overrides. If an endpoint's validation is entangled with an authentication dependency, override the dependency so the test exercises the parameter contract rather than the auth flow. That mechanism is covered in overriding dependencies in tests, and the reusable-alias form that makes it painless is in annotated dependencies and reusable types.
For async endpoints, TestClient still works — it runs the app on its own event loop — but if your test needs to await alongside the request, use httpx.AsyncClient with ASGITransport, which is the same transport the transcripts on this page came from. The trade-offs are in TestClient vs httpx AsyncClient.
Failure Modes and How to Diagnose Them
"The client sends it, FastAPI says it is missing." Read the loc prefix. If it says query and your client put the value in the JSON body, the parameter is a scalar and FastAPI routed it to the query string. If it says body and the name is your parameter name rather than a field name, you have two body parameters and FastAPI has embedded them both.
"My alias is not being applied." Query(alias="customerId") changes the wire name only. Sending customer_id then fails with missing on customerId. This is deliberate — the alias is the contract — but it reads as a bug the first time.
"I get a 422 with a single json_invalid error and no field details." The body was not parseable JSON. Common causes: a Content-Type of application/x-www-form-urlencoded on a JSON payload, or a trailing comma from a hand-rolled client. Field-level errors cannot exist because no fields were ever extracted.
"Extra fields are silently ignored." Pydantic's default is extra="ignore". If a typo'd key should be an error, set model_config = ConfigDict(extra="forbid") on the model and the 422 gains an extra_forbidden entry with the offending key in loc.
"A required path parameter is optional in my code." It is not. A default on a path parameter is ignored — the segment must be present or the route does not match, producing a 404 rather than a 422.
"My 422 shape breaks the mobile client." Do not reshape it ad hoc per endpoint. Install one RequestValidationError handler that maps the standard structure into your envelope, as described in customising validation error responses. Keep type and loc in whatever you emit; they are the only machine-usable parts.
Choosing a Source: a Decision Table
| The value is… | Put it in | Why |
|---|---|---|
| An identifier for the resource being addressed | Path | Cacheable, appears in logs and metrics as part of the route |
| A filter, sort or pagination control | Query | Bookmarkable, safe to log, works with GET |
| The entity being created or replaced | Body (single model) | The payload is the resource representation |
| Two or more independent objects | Body (multiple parameters, auto-embedded) | Each gets its own key; see the parameter validation guide |
| Credentials, tenancy, tracing | Header | Not part of the resource identity; excluded from URLs and caches |
| Browser-managed session state | Cookie | Sent automatically; validate it like any other input |
| Binary content or a browser form post | Form / File | Multipart cannot coexist with a JSON body — see file uploads and forms |
A secret in a query string ends up in access logs, proxy logs and browser history. That alone settles the header-versus-query question for API keys.
Performance Notes
Validation cost is real but rarely dominant. Three things actually matter at throughput:
Body size beats field count. Parsing a 2 MB JSON array is dominated by decoding, not by constraint checks. If you accept large collections, bound them with Field(max_length=...) on the list so an oversized payload is rejected structurally rather than validated element by element.
The dependant is built once. Signature introspection happens at import time, not per request. Declaring twelve Query parameters costs nothing at startup-scale and only the per-value coercion at request time.
Reused annotated types are free. Aliasing Annotated[int, Query(ge=1)] and using it on forty routes does not multiply anything; the metadata objects are shared. The cost model is covered more generally in performance optimization for models.
FAQ
How does FastAPI decide whether a parameter is a query parameter or a body field?
By type and by name. If the parameter name appears in the path template it is a path parameter. Otherwise, scalars such as int, str, bool and their lists default to the query string, while Pydantic models default to the JSON body. An explicit Query, Path, Body, Header, Cookie or Form marker overrides the default.
Why does my 422 list several errors instead of stopping at the first one? FastAPI resolves every parameter before invoking the handler and accumulates the failures, so a single response can report a bad path segment, a bad query value and a bad body field together. This is deliberate: a client fixing a form wants the whole list, not one error per round trip.
What is the ctx key in a validation error?ctx carries the machine-readable parameters of the constraint that failed, such as min_length, gt, le or the expected literal values. It is what you use when you want to render your own error message rather than parse the English msg string.
Does the loc array always start with the parameter source?
For request validation, yes: the first element is path, query, header, cookie or body, and the remaining elements walk into the structure. Errors raised by a response_model are different — they surface as a 500, not a 422, because a broken response is a server bug.
Should I use Annotated or default-value syntax for Query and Path?
Use Annotated. It keeps the metadata in the type rather than in the default slot, which means the real default stays visible, the annotated type can be aliased and reused, and the function remains callable directly in tests without passing a Query object.
Related Reading
- Up to the section: Advanced Pydantic Validation and Serialization for the full validation and serialization picture.
- Go deeper on parameters: Query, Path and Body Parameter Validation for constraints, aliases and list parameters.
- Non-JSON payloads: Validating File Uploads and Forms for multipart, size limits and content-type checks.
- The nullability trap: Optional vs Nullable Fields for required-but-nullable and
exclude_unsetround-tripping. - Reusable declarations: Annotated Dependencies and Reusable Types for turning these declarations into shared aliases.