Rejecting bad input at the edge, with clear 422 errors and documented limits, beats hand-written checks in every endpoint.
Type hints say what type a value is; constraints say which values are acceptable. The modern FastAPI style puts constraints in typing.Annotated: q: Annotated[str | None, Query(max_length=50)] = None. The first argument is the real type, the rest is metadata FastAPI reads, and the default stays an ordinary Python default.
The parameter functions say where a value comes from and carry its constraints:
Query(),Path(),Header(),Cookie()for parameters,Body()for extra values in the body.- Strings:
min_length,max_length,pattern(a regular expression). - Numbers:
gt,ge,lt,le,multiple_of. - Documentation and naming:
title,description,examples,alias(acceptitem-queryin the URL),deprecated=True.
Inside Pydantic models, Field() takes the same constraint names: price: float = Field(gt=0). For rules that need code, write a @field_validator for one field or a @model_validator(mode="after") for cross-field rules like "end must be after start". Raise ValueError with a clear message and Pydantic turns it into a 422 entry for the client.
Since FastAPI 0.115 you can group query parameters into a model: filters: Annotated[FilterParams, Query()]. It's a neat way to share pagination or search parameters, and with model_config = {"extra": "forbid"} unknown query keys are rejected.
Prefer Annotated over the older q: str = Query(None, max_length=50) form. Both work, but Annotated keeps defaults honest, lets the function be called normally in tests, and enables reusable aliases such as ItemId = Annotated[int, Path(gt=0)] shared across many endpoints.
Example
from typing import Annotated
from fastapi import FastAPI, Path, Query
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field, field_validator
ItemId = Annotated[int, Path(gt=0, description="Item id")]
class Review(BaseModel):
stars: int = Field(ge=1, le=5)
text: str = Field(min_length=10, max_length=2000)
@field_validator("text")
@classmethod
def no_shouting(cls, v: str) -> str:
if v.isupper():
raise ValueError("please don't shout")
return v
app = FastAPI()
@app.post("/items/{item_id}/reviews")
def add_review(item_id: ItemId, review: Review,
lang: Annotated[str, Query(pattern="^[a-z]{2}$")] = "en"):
return {"item_id": item_id, "lang": lang, **review.model_dump()}
c = TestClient(app)
print(c.post("/items/0/reviews", json={"stars": 5, "text": "Works really well"}).status_code) # 422
r = c.post("/items/1/reviews", json={"stars": 9, "text": "TERRIBLE PRODUCT!!"})
print([e["loc"][-1] for e in r.json()["detail"]]) # ['stars', 'text']Remember
- Annotated[type, Query(...)/Path(...)/Body(...)] attaches constraints and docs
- Field() puts the same constraints on model fields
- field_validator and model_validator handle rules that need code; raise ValueError
- Reusable aliases like ItemId = Annotated[int, Path(gt=0)] keep endpoints consistent
Watch out for
- Validating in the endpoint and returning 400 by hand, so the rule never shows up in the docs or the standard 422 format.