FastAPI framework with Pydantic v2 patterns, PII sanitisation, and practical workflows
Best practices for using Pydantic v2 with FastAPI. All examples use Pydantic v2 syntax exclusively.
Separate schemas by purpose — never reuse one model for input, update, and output.
from pydantic import BaseModel, ConfigDict
from datetime import datetime
# Input schema — fields the client sends when creating
class UserCreate(BaseModel):
username: str
email: str
password: str
# Update schema — all fields optional for partial updates
class UserUpdate(BaseModel):
username: str | None = None
email: str | None = None
# Response schema — what the API returns (no password, includes server fields)
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
email: str
created_at: datetimeUse in endpoints:
from fastapi import FastAPI
app = FastAPI()
@app.post("/users", response_model=UserResponse, status_code=201)
def create_user(payload: UserCreate):
user = save_to_db(payload)
return user
@app.patch("/users/{user_id}", response_model=UserResponse)
def update_user(user_id: int, payload: UserUpdate):
user = patch_in_db(user_id, payload.model_dump(exclude_unset=True))
return userAlways declare response_model= on every endpoint. This acts as an allowlist — only fields defined in the response model are serialised, preventing accidental PII leakage.
# Good — response_model controls what gets returned
@app.get("/users/{user_id}", response_model=UserResponse)
def get_user(user_id: int):
return db.get(user_id) # extra fields like password are stripped
# Bad — no response_model, raw dict/ORM object goes straight to client
@app.get("/users/{user_id}")
def get_user(user_id: int):
return db.get(user_id) # may leak internal fieldsUse Field() for constraints and metadata. Use ConfigDict (not inner class Config).
from pydantic import BaseModel, Field, ConfigDict
class Product(BaseModel):
model_config = ConfigDict(
str_strip_whitespace=True,
from_attributes=True,
)
name: str = Field(min_length=1, max_length=200)
price: float = Field(gt=0, description="Price in USD")
sku: str = Field(pattern=r"^[A-Z]{2}-\d{4}$")
tags: list[str] = Field(default_factory=list, max_length=10)Use @field_validator for single-field validation and @model_validator for cross-field logic.
from pydantic import BaseModel, field_validator, model_validator
class Order(BaseModel):
quantity: int
unit_price: float
discount: float = 0.0
@field_validator("quantity")
@classmethod
def quantity_positive(cls, v):
if v <= 0:
raise ValueError("quantity must be positive")
return v
@model_validator(mode="after")
def discount_within_total(self):
total = self.quantity * self.unit_price
if self.discount > total:
raise ValueError("discount exceeds order total")
return selfPre-validation (operate on raw input before field parsing):
class NormalisedEmail(BaseModel):
email: str
@field_validator("email", mode="before")
@classmethod
def lowercase_email(cls, v):
if isinstance(v, str):
return v.lower().strip()
return vUse @computed_field with @property for derived values included in serialisation.
from pydantic import BaseModel, computed_field
class CartItem(BaseModel):
name: str
quantity: int
unit_price: float
@computed_field
@property
def total_price(self) -> float:
return self.quantity * self.unit_priceUse model_dump() and model_dump_json() — never .dict() or .json().
user = UserResponse(id=1, username="alice", email="a@b.com", created_at=datetime.now())
# Dict output
data = user.model_dump() # all fields
data = user.model_dump(exclude_unset=True) # only explicitly set fields
data = user.model_dump(exclude={"email"}) # drop specific fields
data = user.model_dump(include={"id", "username"}) # keep only these fields
# JSON string
json_str = user.model_dump_json(indent=2)Custom serialisation with @field_serializer:
from pydantic import BaseModel, field_serializer
from datetime import datetime
class Event(BaseModel):
name: str
starts_at: datetime
@field_serializer("starts_at")
def serialize_starts_at(self, v: datetime, _info):
return v.strftime("%Y-%m-%d %H:%M")Pydantic models compose naturally as FastAPI request/response types.
class Address(BaseModel):
street: str
city: str
country: str
class CompanyResponse(BaseModel):
name: str
headquarters: Address
offices: list[Address] = []
@app.get("/company/{id}", response_model=CompanyResponse)
def get_company(id: int):
return db.get_company(id)Validate non-model types (lists, unions, primitives) without wrapping in a model.
from pydantic import TypeAdapter
# Validate a list of integers
int_list_adapter = TypeAdapter(list[int])
result = int_list_adapter.validate_python(["1", "2", "3"]) # [1, 2, 3]
# Validate a union type
from typing import Union
adapter = TypeAdapter(Union[int, str])
adapter.validate_python(42) # 42
adapter.validate_python("hi") # "hi"
# Generate JSON schema for non-model types
schema = int_list_adapter.json_schema()Use with FastAPI for validating query parameters or complex types outside of Pydantic models.
tessl i maria/fastapi@0.1.0