Query, Path and Body Parameter Validation in FastAPI
Key takeaways:
Query,PathandBodycarry the same Pydantic constraints:gt,ge,lt,le,min_length,max_length,pattern.- Declaring a parameter as
list[T]collects a repeated query key; failures report the element index inloc. aliasreplaces the wire name outright — the Python name stops being accepted.- One body parameter means the payload is the model; two or more means every one gets embedded under its name.
Literalbeats a hand-written enum check: it validates, documents itself in OpenAPI, and produces aliteral_errorlisting the allowed values.
This page is the hands-on companion to Request Validation Patterns, which explains how FastAPI decides a parameter's source. Here we assume the source is settled and focus on constraining the value.
Every transcript below is the real output of _verify/examples/val-query-path-body.py executed on FastAPI 0.139.2, Pydantic 2.13.4 and Python 3.12.
The Problem This Solves
You have an endpoint that pages through products. A client sends page=0, and your ORM builds a query with OFFSET -20. Another sends per_page=100000 and your service spends ninety seconds serialising. A third sends sort=created, which your code silently maps to nothing. None of these are database problems or business-logic problems; they are all "the interface accepted a value it should have rejected".
Declaring the constraint at the boundary fixes every one of them in a place that also documents itself in OpenAPI. The alternative — an if at the top of each handler — is invisible to clients, untested by default, and duplicated across every endpoint that pages.
Why the Constraints Behave the Way They Do
Query, Path, Body, Header, Cookie and Form are all thin subclasses of the same FastAPI Param/Body machinery, and every one of them forwards its constraint keywords into a Pydantic FieldInfo. That is why the vocabulary is identical to Field: gt, ge, lt, le, multiple_of for numbers; min_length, max_length, pattern for strings and sequences. There is no separate "FastAPI validator" — pydantic-core does the work, which is why the error type values on a rejected query parameter are the same ones you see inside a model.
The consequence: the constraint you can express on a model field, you can express on a parameter, including Annotated-attached custom validators. And the error you get back has the same five-key shape, differing only in the loc prefix.
Path Parameters
Path parameters are always required and always strings on the wire. Constraints are about the coerced value:
@app.get("/reports/{year}/{slug}")
async def get_report(
year: Annotated[int, Path(ge=2000, le=2100)],
slug: Annotated[str, Path(pattern=r"^[a-z0-9-]+$")],
) -> dict[str, Any]:
return {"year": year, "slug": slug}
Real output:
$ GET /reports/2026/q2-revenue
200 OK
{
"year": 2026,
"slug": "q2-revenue"
}
$ GET /reports/1999/Q2_Revenue
422 Unprocessable Entity
{
"detail": [
{
"type": "greater_than_equal",
"loc": [
"path",
"year"
],
"msg": "Input should be greater than or equal to 2000",
"input": "1999",
"ctx": {
"ge": 2000
}
},
{
"type": "string_pattern_mismatch",
"loc": [
"path",
"slug"
],
"msg": "String should match pattern '^[a-z0-9-]+$'",
"input": "Q2_Revenue",
"ctx": {
"pattern": "^[a-z0-9-]+$"
}
}
]
}
Notice "input": "1999" — a string, not the number 1999. The constraint failed after coercion, but input reports the raw value as it arrived. That is genuinely useful in logs: you can see exactly what the client put on the wire.
A slug pattern like this one is worth the two seconds it takes to write. It closes off path traversal attempts, stops case-variant duplicates from reaching your cache keys, and turns "we got a weird 500 from the CDN" into a clean 422.
Query Parameters
The everyday case is pagination and filtering:
@app.get("/products/")
async def list_products(
q: Annotated[str | None, Query(min_length=2, max_length=40)] = None,
page: Annotated[int, Query(ge=1)] = 1,
per_page: Annotated[int, Query(ge=1, le=100)] = 20,
sort: Annotated[Literal["price", "name", "-price"], Query()] = "name",
) -> dict[str, Any]:
return {"q": q, "page": page, "per_page": per_page, "sort": sort}
One request violating all four:
$ GET /products/?q=a&page=0&per_page=500&sort=created
422 Unprocessable Entity
{
"detail": [
{
"type": "string_too_short",
"loc": [
"query",
"q"
],
"msg": "String should have at least 2 characters",
"input": "a",
"ctx": {
"min_length": 2
}
},
{
"type": "greater_than_equal",
"loc": [
"query",
"page"
],
"msg": "Input should be greater than or equal to 1",
"input": "0",
"ctx": {
"ge": 1
}
},
{
"type": "less_than_equal",
"loc": [
"query",
"per_page"
],
"msg": "Input should be less than or equal to 100",
"input": "500",
"ctx": {
"le": 100
}
},
{
"type": "literal_error",
"loc": [
"query",
"sort"
],
"msg": "Input should be 'price', 'name' or '-price'",
"input": "created",
"ctx": {
"expected": "'price', 'name' or '-price'"
}
}
]
}
Literal deserves a moment. It costs one import, it rejects unknown sort keys before they reach your ORM, it renders as an enum dropdown in Swagger UI, and its ctx.expected hands the client the valid set. A hand-rolled if sort not in {...}: raise HTTPException(400) gives you none of that and does not appear in the schema at all.
And the per_page ceiling is not pedantry: an unbounded page size is the single most reliable way for one client to exhaust your connection pool. That failure mode and its blast radius are covered in fixing asyncpg pool exhaustion.
Repeated keys as lists
@app.get("/products/by-tag")
async def by_tag(
tag: Annotated[list[str], Query(min_length=1)] = [],
ids: Annotated[list[int] | None, Query(alias="id")] = None,
) -> dict[str, Any]:
# A repeated query key collects into a list; alias renames the wire key.
return {"tag": tag, "ids": ids}
$ GET /products/by-tag?tag=sale&tag=new&id=1&id=2
200 OK
{
"tag": [
"sale",
"new"
],
"ids": [
1,
2
]
}
$ GET /products/by-tag?tag=&id=abc
422 Unprocessable Entity
{
"detail": [
{
"type": "int_parsing",
"loc": [
"query",
"id",
0
],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"input": "abc"
}
]
}
Two things to read off that transcript. First, the element index appears in loc: ["query", "id", 0]. A client rendering error messages next to individual chips in a tag input has everything it needs. Second, min_length=1 on a list[str] constrains the list, not its elements — tag= produced a one-element list containing the empty string, which satisfies "at least one item". To constrain the elements, put the constraint on the element type: list[Annotated[str, Field(min_length=1)]].
The mutable default = [] is safe here in a way it is not in ordinary Python, because FastAPI never mutates it; it is read as the field default on each request. It still reads badly, and = None with a list[str] | None annotation is the clearer habit.
Aliases for wire names you do not control
@app.get("/legacy/")
async def legacy(
customer_id: Annotated[int, Query(alias="customerId")],
include: Annotated[str | None, Query(alias="include[]")] = None,
) -> dict[str, Any]:
return {"customer_id": customer_id, "include": include}
$ GET /legacy/?customerId=88&include[]=orders
200 OK
{
"customer_id": 88,
"include": "orders"
}
$ GET /legacy/?customer_id=88
422 Unprocessable Entity
{
"detail": [
{
"type": "missing",
"loc": [
"query",
"customerId"
],
"msg": "Field required",
"input": null
}
]
}
The alias is the contract. customer_id is now simply not a parameter this endpoint has. That is usually what you want — one wire name, one meaning — but it makes aliasing a breaking change for existing callers. During a migration, accept both by using Pydantic's AliasChoices in a Query(validation_alias=AliasChoices("customerId", "customer_id")), ship it, then remove the old name once your client metrics show it unused.
include[] shows the other reason aliases exist: bracket-suffixed keys from PHP-era clients and some HTTP libraries are not valid Python identifiers. The alias is the only way to accept them.
Body Parameters
With one body parameter, the JSON payload is the model:
class Discount(BaseModel):
code: str = Field(min_length=4, max_length=16, pattern=r"^[A-Z0-9]+$")
percent: int = Field(gt=0, le=90)
@app.post("/discounts/")
async def create_discount(discount: Discount) -> dict[str, Any]:
return discount.model_dump()
$ POST /discounts/ {"code": "SUMMER26", "percent": 25}
200 OK
{
"code": "SUMMER26",
"percent": 25
}
$ POST /discounts/ {"code": "sum", "percent": 95}
422 Unprocessable Entity
{
"detail": [
{
"type": "string_too_short",
"loc": [
"body",
"code"
],
"msg": "String should have at least 4 characters",
"input": "sum",
"ctx": {
"min_length": 4
}
},
{
"type": "less_than_equal",
"loc": [
"body",
"percent"
],
"msg": "Input should be less than or equal to 90",
"input": 95,
"ctx": {
"le": 90
}
}
]
}
Here input is the integer 95, not the string "95", because JSON carries types. Compare with the query-string transcripts above, where every input is a string. If you build alerting on validation failures, that difference matters for your log schema.
The single-model versus multiple-parameters rule
This is the rule that breaks clients in production, so it is worth stating precisely:
- Exactly one body parameter, and it is a model: the request body is that model's JSON object directly.
- Exactly one body parameter with
Body(embed=True): the body becomes{"<param_name>": {…}}. - Two or more body parameters (models or
Body()scalars): FastAPI embeds every one under its parameter name automatically. There is no way to keep one of them flat.
@app.post("/campaigns/")
async def create_campaign(
campaign: Campaign,
discount: Discount,
priority: Annotated[int, Body(ge=0, le=9)] = 0,
) -> dict[str, Any]:
# Multiple body parameters -> every one is embedded under its parameter name.
return {"campaign": campaign.model_dump(), "discount": discount.model_dump(), "priority": priority}
$ POST /campaigns/ {"campaign": {"name": "Summer"}, "discount": {"code": "SUMMER26", "percent": 25}, "priority": 3}
200 OK
{
"campaign": {
"name": "Summer"
},
"discount": {
"code": "SUMMER26",
"percent": 25
},
"priority": 3
}
$ POST /campaigns/ {"name": "Summer", "code": "SUMMER26", "percent": 25}
422 Unprocessable Entity
{
"detail": [
{
"type": "missing",
"loc": [
"body",
"campaign"
],
"msg": "Field required",
"input": null
},
{
"type": "missing",
"loc": [
"body",
"discount"
],
"msg": "Field required",
"input": null
}
]
}
The second transcript is the shape of the outage. A deployed endpoint took a flat Discount; someone added a campaign: Campaign parameter; every existing client immediately started getting missing errors on discount — a name that appears nowhere in their code, because it is your Python parameter name.
The defensive habit: if an endpoint might ever grow a second body input, declare a wrapper model with both as fields from day one. CreateCampaignRequest(campaign=…, discount=…) gives you the same JSON shape as auto-embedding, but the shape is explicit in your code, versioned with your schema, and adding a third field is an additive change rather than a structural flip. Where the endpoints themselves need to change shape, versioning APIs with routers is the escape hatch.
Verification
The fastest confirmation is the generated schema — the constraints are in it, so a client generator will enforce them too:
def test_openapi_carries_constraints():
schema = app.openapi()
params = schema["paths"]["/products/"]["get"]["parameters"]
per_page = next(p for p in params if p["name"] == "per_page")
assert per_page["schema"]["maximum"] == 100
assert per_page["schema"]["minimum"] == 1
For the body-shape rule, assert on the shape rather than on prose:
def test_multiple_body_params_are_embedded():
body = app.openapi()["paths"]["/campaigns/"]["post"]["requestBody"]
ref = body["content"]["application/json"]["schema"]["$ref"]
assert ref.endswith("Body_create_campaign_campaigns__post")
That generated Body_… model name is FastAPI telling you it built a wrapper. Seeing it in your OpenAPI diff is the earliest possible warning that a body contract just changed shape — worth wiring into CI alongside the practices in customizing OpenAPI schema generation.
Trade-offs and When Not To
Constraints on the parameter versus on the model. A per_page ceiling belongs on Query — it is a property of this endpoint's capacity, not of any domain object. A discount code's format belongs on the model, because it should hold anywhere a discount code appears, including in background jobs that never see an HTTP request.
Patterns are a blunt instrument. pattern=r"^[a-z0-9-]+$" is excellent for slugs and terrible for email addresses or names, where it will reject legitimate international input. Where a regex would need exceptions, use a Pydantic type such as EmailStr or an AfterValidator that expresses the actual rule; see creating reusable custom validators.
Very strict input is a compatibility liability. Adding pattern to an existing parameter can reject traffic that has worked for a year. Deploy new constraints in report-only mode first: log what would have been rejected, look at the numbers, then enforce.
Do not encode business rules as parameter constraints. "Discount may not exceed 90%" is arguably a business rule that will change by market and by campaign. If it lives in Field(le=90) it also lives in your public schema, and changing it is an API change. Rules that vary by context belong in the service layer.
FAQ
Why did adding a second body parameter break my existing clients?
With one body parameter the JSON payload is the model itself. Add a second and FastAPI embeds both under their parameter names, so the payload must become an object keyed by those names. Existing clients then get a 422 with missing errors whose loc names your Python parameter, not a model field.
How do I accept the same query parameter repeated several times?
Declare it as a list, for example Annotated[list[str], Query()]. FastAPI collects every occurrence of the key into the list. Element-level failures report an index in loc, such as query, id, 0.
Does Query(alias=...) still accept the original Python name?
No. The alias replaces the wire name entirely, so sending the snake_case name produces a missing error on the aliased name. Use validation_alias with an AliasChoices if you genuinely need to accept both during a migration.
Can a path parameter have a default value? No. The path segment must be present for the route to match at all, so a default is unreachable. A request without the segment gets a 404 from the router, not a 422 from validation.
Where should a constraint live — on Query or on the Pydantic model?
Put it on the model when the rule belongs to the data and should appear wherever that data is used. Put it on Query or Path when the rule is about this endpoint's interface, such as a pagination ceiling that other callers of the same model do not share.
Related Reading
- Up to the guide: Request Validation Patterns for how FastAPI picks a parameter's source in the first place.
- Sibling pages: Validating File Uploads and Forms for the multipart equivalent of these rules, and Optional vs Nullable Fields for what a default really means.
- Reusing declarations: Annotated Dependencies and Reusable Types turns a repeated
Query(ge=1)into a named type. - Shaping the errors: Customising Validation Error Responses if the default 422 envelope does not suit your clients.