Skip to content

Chapter 5 · Advanced Validation with Annotated ​

Chapter goals: Master the official recommended Annotated declarative validation style,熟练使用数值/长度/正则约束与自定义校验器,并能读懂 422 错误响应。

Starting from version 0.95.0, FastAPI officially recommends using Python's standard Annotated to declare parameter metadata:

python
from typing import Annotated
from fastapi import FastAPI, Query

app = FastAPI()


@app.get("/items/")
def read_items(
    q: Annotated[str | None, Query(max_length=50)] = None,
    #     type ─────────────────────┘        └── validation metadata
):
    # GET /items/?q=hello passes; q over 50 chars returns 422
    return {"q": q}

The structure is Annotated[actual_type, metadata1, metadata2, ...]. The default value still goes after the parameter's =. Compare with the old style:

python
# ❌ Old style (before 0.95.0): Query occupies the default value position
def read_items(q: str | None = Query(default=None, max_length=50)): ...

# ✅ New style: default value stays a default; validation info goes in Annotated
def read_items(q: Annotated[str | None, Query(max_length=50)] = None): ...

Official advantages of Annotated:

  • Default value semantics are not polluted: calling this function directly (without FastAPI) treats the parameter as a normal parameter, not receiving a QueryInfo object;
  • Reusable: the Annotated type can be extracted into an alias and shared across endpoints;
  • Compatible with other tools: the same function can be used by Typer and other Annotated-based libraries.

You cannot use Query(default=...) inside Annotated

Annotated[str, Query(default="rick")] = "morty" raises an error. Inside Annotated, Query() does not accept a default parameter — the default only goes after the parameter =.

5.2 String Constraints: Length and Regex ​

Query / Path / Body / Field share the same constraint parameters:

python
from typing import Annotated
from fastapi import FastAPI, Query

app = FastAPI()


@app.get("/search/")
def search(
    q: Annotated[
        str,
        Query(
            min_length=3,                      # At least 3 characters
            max_length=50,                     # At most 50 characters
            pattern=r"^[\w\u4e00-\u9fa5 ]+$",  # Only alphanumeric, underscore, CJK, space
            title="Search keyword",
            description="Length 3-50, only Chinese/English letters, digits, and spaces",
        ),
    ],
):
    return {"q": q}

pattern uses regex to constrain format; ^fixedquery$ would be an exact match. title / description do not affect validation but appear in the OpenAPI docs.

5.3 Numeric Constraints: gt / ge / lt / le ​

Path parameters can also be validated, using Path:

python
from typing import Annotated
from fastapi import FastAPI, Path, Query

app = FastAPI()


@app.get("/items/{item_id}")
def read_item(
    item_id: Annotated[int, Path(title="Product ID", gt=0, lt=10000)],
    size: Annotated[float, Query(ge=0.1, le=10)] = 1.0,
):
    # item_id must be an integer from 1..9999 (gt=0 excludes 0 and negatives)
    # size must be between 0.1 and 10 (inclusive; ge=greater or equal, le=less or equal)
    return {"item_id": item_id, "size": size}

Memorize these four abbreviations: gt=greater than (strict), ge=greater or equal, lt=less than, le=less or equal. To express "must be positive but can be less than 1", use gt=0; ge=1 would incorrectly reject 0.5.

5.4 Pydantic Model Field Validation: Field ​

Request body fields use Field, with parameter names identical to Query/Path:

python
from pydantic import BaseModel, Field
from fastapi import FastAPI

app = FastAPI()


class Product(BaseModel):
    name: str = Field(min_length=1, max_length=100, examples=["Mechanical Keyboard"])
    price: float = Field(gt=0, description="Unit price, must be greater than 0")
    stock: int = Field(default=0, ge=0)
    sku: str = Field(pattern=r"^[A-Z]{3}-\d{4}$")   # Format like ABC-1234


@app.post("/products/")
def create(p: Product):
    return p.model_dump()

5.5 Custom Validators ​

For validation logic that cannot be expressed with built-in constraints, use Pydantic's @field_validator:

python
from pydantic import BaseModel, field_validator
from fastapi import FastAPI

app = FastAPI()


class Signup(BaseModel):
    username: str
    password: str

    @field_validator("username")
    @classmethod
    def username_not_reserved(cls, v: str) -> str:
        reserved = {"admin", "root", "system"}
        if v.lower() in reserved:
            raise ValueError(f"'{v}' is a reserved username")
        return v

    @field_validator("password")
    @classmethod
    def password_strong(cls, v: str) -> str:
        if len(v) < 8:
            raise ValueError("Password must be at least 8 characters")
        if not any(c.isupper() for c in v):
            raise ValueError("Password must contain an uppercase letter")
        return v

5.6 Reading 422 Error Responses ​

When validation fails, FastAPI returns HTTP 422 with a structured JSON body:

json
{
  "detail": [
    {
      "type": "string_too_short",
      "loc": ["query", "q"],
      "msg": "String should have at least 3 characters",
      "input": "ab",
      "ctx": {"limit_value": 3}
    }
  ]
}

The loc array tells you exactly where the error is: ["body", "item", "price"] means the price field inside the item body object. type is a machine-readable error code; msg is a human-readable description.

5.7 Chapter Summary ​

  • Annotated[type, Constraint()] is the official recommended style (since 0.95.0);
  • String: min_length, max_length, pattern; Numeric: gt, ge, lt, le;
  • Field(...) in Pydantic models uses the same constraints;
  • @field_validator for custom validation logic;
  • 422 errors include loc, type, msg, and input for precise debugging.

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. What is the correct Annotated syntax for a required query param with min_length=3?

2. What is the difference between gt=0 and ge=1 for validating "positive numbers"?

3. Where do you put custom validation logic that cannot be expressed with built-in constraints?

4. In a 422 error response, what does the loc field tell you?

🛠️ Hands-On Exercises ​

  1. Write a signup endpoint with Annotated[str, Query(min_length=3, max_length=50)] for the username; test with valid, too-short, and too-long inputs.
  2. Add a @field_validator to a Pydantic model that rejects usernames containing numbers; verify the 422 error message.
  3. Build a nested Product model with List[Image] and dict[str, float]; send an invalid nested structure and inspect the loc array in the 422 response.

Proceed to Chapter 6: Response Models and Status Codes.