Chapter 5 · Advanced Validation with Annotated
Chapter goals: Master the official recommended Annotated declarative validation style,熟练使用数值/长度/正则约束与自定义校验器,并能读懂 422 错误响应。
5.1 Annotated: The Official Recommended Style
Starting from version 0.95.0, FastAPI officially recommends using Python's standard Annotated to declare parameter metadata:
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:
# ❌ 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
QueryInfoobject; - Reusable: the
Annotatedtype 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:
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:
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:
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:
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 v5.6 Reading 422 Error Responses
When validation fails, FastAPI returns HTTP 422 with a structured JSON body:
{
"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_validatorfor custom validation logic;- 422 errors include
loc,type,msg, andinputfor 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
- 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. - Add a
@field_validatorto a Pydantic model that rejects usernames containing numbers; verify the 422 error message. - Build a nested Product model with
List[Image]anddict[str, float]; send an invalid nested structure and inspect thelocarray in the 422 response.
Proceed to Chapter 6: Response Models and Status Codes.