Why Pydantic models matter: clear API contracts
In FastAPI, Pydantic models define the shape of data your API accepts and returns. This gives you a clear contract: clients know exactly what to send, and you control exactly what leaves your service. Pydantic also validates and coerces input data (for example, turning JSON strings into datetime objects), producing consistent, predictable Python objects inside your endpoint code.
Think of request models as “what the client is allowed to send” and response models as “what the client is allowed to see.” Keeping these explicit and separate helps prevent accidental exposure of internal fields (like password hashes, internal IDs, or audit metadata).
Defining request body models with BaseModel
Required vs optional fields
A field is required when it has no default value. A field is optional when it has a default (often None) or is wrapped in Optional[T] (or T | None in Python 3.10+).
from pydantic import BaseModel, EmailStr, Field
from typing import Optional
class UserCreate(BaseModel):
email: EmailStr # required
password: str = Field(min_length=8) # required (no default)
full_name: Optional[str] = None # optional
is_marketing_opt_in: bool = False # optional with default
emailis required because it has no default.full_nameis optional because it defaults toNone.is_marketing_opt_inis optional because it has a default value.
Default values and Field(...) constraints
Use Field to add validation constraints and metadata. Constraints help enforce business rules early, before your logic runs.
from pydantic import BaseModel, Field
class ProductCreate(BaseModel):
name: str = Field(min_length=2, max_length=100)
price_cents: int = Field(ge=0)
sku: str = Field(pattern=r"^[A-Z0-9_-]{6,20}$")
Common constraints include min_length, max_length, ge, le, and pattern.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
Nested objects: modeling structured JSON
Real APIs often accept nested JSON. Pydantic supports nested models directly, which keeps validation and typing clean.
from pydantic import BaseModel, Field
class Address(BaseModel):
line1: str
city: str
country: str = Field(min_length=2, max_length=2) # ISO country code
class UserProfileCreate(BaseModel):
full_name: str
address: Address
A client can send:
{
"full_name": "Ada Lovelace",
"address": {
"line1": "12 Example Street",
"city": "London",
"country": "GB"
}
}
Inside your endpoint, profile.address is an Address instance, not a raw dict.
Separate schemas for create, update, and read
A common pattern is to define multiple schemas for the same resource. This avoids mixing concerns like “what the client can set” vs “what the server returns.”
Create schema
Used for POST requests. Typically includes required fields needed to create the resource.
from pydantic import BaseModel, Field
class ItemCreate(BaseModel):
name: str = Field(min_length=1)
description: str | None = None
Update schema (PATCH-like behavior)
For updates, fields are often optional so the client can send only what changes. This is usually a different model than create.
from pydantic import BaseModel, Field
class ItemUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1)
description: str | None = None
When applying updates, you typically merge only provided fields. With Pydantic v2, use model_dump(exclude_unset=True) to get only fields the client sent.
# item_update is an ItemUpdate
patch_data = item_update.model_dump(exclude_unset=True)
# patch_data contains only keys provided by the client
Read schema (response shape)
Read schemas represent what your API returns. They often include server-generated fields like id and timestamps, but exclude sensitive/internal fields.
from pydantic import BaseModel
from datetime import datetime
class ItemRead(BaseModel):
id: int
name: str
description: str | None
created_at: datetime
Using response_model to control outbound data
FastAPI’s response_model parameter lets you declare the response schema for an endpoint. FastAPI will validate and serialize the returned value according to that schema, filtering out extra fields. This is a key tool for preventing overexposure.
Example: filtering internal fields
Imagine your internal representation includes a secret field that must never be returned.
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr
app = FastAPI()
class UserRead(BaseModel):
id: int
email: EmailStr
# Internal data (could be from a DB)
FAKE_USER_ROW = {
"id": 1,
"email": "user@example.com",
"password_hash": "pbkdf2:...",
"is_admin": True
}
@app.get("/users/me", response_model=UserRead)
def read_me():
# Even if we return extra keys, response_model filters them out
return FAKE_USER_ROW
The response will include only id and email. This helps you safely return “rich” internal objects without leaking fields.
Shaping lists and nested responses
response_model can be a list type or a nested model.
from typing import List
@app.get("/items", response_model=List[ItemRead])
def list_items():
return [
{"id": 1, "name": "A", "description": None, "created_at": "2026-01-01T10:00:00Z", "internal_note": "x"},
{"id": 2, "name": "B", "description": "...", "created_at": "2026-01-02T10:00:00Z", "internal_note": "y"},
]
Extra keys like internal_note are removed from the output.
Model reuse across endpoints
To avoid duplication, factor shared fields into base schemas and inherit. This keeps your API consistent and reduces maintenance.
from pydantic import BaseModel, Field
class ItemBase(BaseModel):
name: str = Field(min_length=1)
description: str | None = None
class ItemCreate(ItemBase):
pass
class ItemUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1)
description: str | None = None
class ItemRead(ItemBase):
id: int
Here, ItemRead reuses the same fields as ItemBase and adds id. ItemCreate reuses the base fields as-is.
Step-by-step: implement endpoints that enforce request and response schemas
This exercise-like implementation focuses on schema enforcement. It uses an in-memory store to keep the focus on Pydantic models and response_model behavior.
1) Define schemas
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from datetime import datetime
app = FastAPI()
class ItemBase(BaseModel):
name: str = Field(min_length=1, max_length=100)
description: str | None = Field(default=None, max_length=500)
class ItemCreate(ItemBase):
pass
class ItemUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
description: str | None = Field(default=None, max_length=500)
class ItemRead(ItemBase):
id: int
created_at: datetime
2) Create an internal storage shape (with extra fields)
We’ll store extra internal fields to demonstrate how response_model prevents overexposure.
_items: dict[int, dict] = {}
_next_id = 1
3) Implement create endpoint (request model: ItemCreate, response model: ItemRead)
@app.post("/items", response_model=ItemRead, status_code=201)
def create_item(payload: ItemCreate):
global _next_id
item_id = _next_id
_next_id += 1
now = datetime.utcnow()
# Internal representation includes fields not meant for clients
row = {
"id": item_id,
"name": payload.name,
"description": payload.description,
"created_at": now,
"internal_cost_cents": 1234,
"internal_note": "do not expose"
}
_items[item_id] = row
return row
Even though row contains internal keys, the client receives only ItemRead fields.
4) Implement read endpoint (response model: ItemRead)
@app.get("/items/{item_id}", response_model=ItemRead)
def get_item(item_id: int):
row = _items.get(item_id)
if not row:
raise HTTPException(status_code=404, detail="Item not found")
return row
5) Implement update endpoint (request model: ItemUpdate, response model: ItemRead)
This endpoint applies only the fields the client provided.
@app.patch("/items/{item_id}", response_model=ItemRead)
def update_item(item_id: int, payload: ItemUpdate):
row = _items.get(item_id)
if not row:
raise HTTPException(status_code=404, detail="Item not found")
patch_data = payload.model_dump(exclude_unset=True)
# Apply only provided fields
row.update(patch_data)
_items[item_id] = row
return row
6) Implement list endpoint (response model: list of ItemRead)
@app.get("/items", response_model=list[ItemRead])
def list_items():
return list(_items.values())
Try it: payloads to validate behavior
| Action | Example JSON | What to observe |
|---|---|---|
| Create | | Returns id and created_at; does not return internal fields. |
| Create (invalid) | | Validation error because name must have at least 1 character. |
| Patch | | Only description changes; name remains unchanged. |
Extension tasks (optional)
- Add a nested object field (for example,
manufacturerwithnameandcountry) and ensure both create and read schemas include it. - Create a separate
ItemReadPublicschema that intentionally hidesdescriptionand use it on the list endpoint to return a slimmer payload. - Add a
tags: list[str]field with a default empty list and validate that it always serializes as an array.