Pydantic Models and Validation

Introduction

Pydantic models define the shape and rules of your data. FastAPI uses them for JSON bodies, query structures, and response filtering. This chapter covers Pydantic v2 basics, validators, and the common pattern of separate Create / Read / Update schemas.

Prerequisites

BaseModel Basics

python
from pydantic import BaseModel, Field
 
class ItemCreate(BaseModel):
    name: str = Field(..., min_length=1, max_length=200)
    description: str | None = None
    price: float = Field(..., gt=0)
    tax: float | None = None

Code explanation:

  • ... in Field means required
  • str | None = None is optional (Python 3.10+ syntax)

Use in route:

python
@app.post("/items")
def create_item(item: ItemCreate):
    total = item.price + (item.tax or 0)
    return {"name": item.name, "total": total}

Separate Input and Output Schemas

Never return password hashes or internal IDs you did not intend to expose:

python
class UserCreate(BaseModel):
    username: str
    password: str
 
class UserRead(BaseModel):
    id: int
    username: str
 
@app.post("/users", response_model=UserRead)
def create_user(user: UserCreate):
    # hash password, save to DB (later chapters)
    return UserRead(id=1, username=user.username)

Code explanation:

  • UserCreate accepts password
  • UserRead omits password from API responses

model_config for ORM (from_attributes)

When reading SQLAlchemy objects (later):

python
from pydantic import ConfigDict
 
class ItemRead(BaseModel):
    model_config = ConfigDict(from_attributes=True)
 
    id: int
    name: str
    price: float

Enables ItemRead.model_validate(db_item) from ORM instances.

Nested Models

python
class Image(BaseModel):
    url: str
    alt: str | None = None
 
class ItemCreate(BaseModel):
    name: str
    images: list[Image] = []

JSON:

json
{
  "name": "Camera",
  "images": [{"url": "https://example.com/a.jpg", "alt": "Front"}]
}

Field Validators (Pydantic v2)

Model validator (cross-field):

python
from pydantic import model_validator
 
class Range(BaseModel):
    start: int
    end: int
 
    @model_validator(mode="after")
    def check_range(self):
        if self.end < self.start:
            raise ValueError("end must be >= start")
        return self

Email and Constrained Types

bash
pip install email-validator
python
from pydantic import EmailStr
 
class UserCreate(BaseModel):
    email: EmailStr
    username: str

Partial Updates (PATCH)

python
class ItemUpdate(BaseModel):
    name: str | None = None
    price: float | None = Field(default=None, gt=0)
 
@app.patch("/items/{item_id}")
def patch_item(item_id: int, data: ItemUpdate):
    updates = data.model_dump(exclude_unset=True)
    return {"item_id": item_id, "updates": updates}

Code explanation:

  • exclude_unset=True returns only fields the client sent
  • Ideal for PATCH semantics

model_dump and JSON

python
item = ItemCreate(name="Book", price=19.99)
item.model_dump()
item.model_dump_json()

Pydantic v1 used .dict()—use model_dump() in v2.

Tip

Keep Schemas in schemas/ Package

Split schemas/user.py, schemas/item.py as projects grow—routes stay thin.

FAQ

ValidationError vs HTTP 422?

FastAPI converts Pydantic validation failures to 422 responses automatically.

Optional list default mutable?

Use Field(default_factory=list) instead of = [] for safety.

Decimal for money?

Use Decimal type with JSON encoder config or store cents as int.

Union types?

str | int works—document in OpenAPI; clients must send consistent types.

Same model for create and read?

Small demos OK—production should split schemas.

Pydantic v1 tutorials online?

Replace class Config: with model_config = ConfigDict(...) and .dict() with model_dump().