Validation with Annotated, Query, Path and Field

FastAPI · Practical · 14 min

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 (accept item-query in 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.

Check yourself

  1. Which is the recommended way to limit an optional query parameter q to 50 characters?
  2. A body model needs the rule 'end_date must be after start_date'. What's the best tool?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: fastapi.tiangolo.com/tutorial/query-params-str-validations/