FastAPI Routes and Pydantic Models
A practical guide to FastAPI routing and Pydantic v2 — path operations, path/query/body parameters, model validation, response_model, and response_model_exclude_unset.
What you'll learn
- ✓How FastAPI maps HTTP methods to Python functions
- ✓How to declare path, query, and body parameters from type hints
- ✓How Pydantic v2 BaseModel validates request bodies
- ✓How to constrain values with Field and Annotated
- ✓How response_model shapes the JSON you return
- ✓When and why to use response_model_exclude_unset
Prerequisites
- •You have a FastAPI app running — see What Is FastAPI?
A FastAPI service is a collection of path operations — functions you decorate with an HTTP method and a URL pattern — plus the Pydantic models that describe what flows in and out. Once those two pieces click, the rest of the framework is small. This post walks through them together with runnable examples.
Path operations
A path operation is a function bound to a method and a URL by a decorator on the app object:
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
The decorator name matches the HTTP method: get, post, put, patch, delete. The string argument is the path; placeholders in curly braces become parameters:
@app.get("/users/{user_id}")
def read_user(user_id: int) -> dict[str, int]:
return {"user_id": user_id}
The type hint on user_id does two jobs at once: it tells your editor what the value is, and it tells FastAPI to coerce the URL segment to int. If the client requests /users/abc, FastAPI returns a structured 422 error automatically.
Query parameters
Parameters that are not in the path become query string parameters:
@app.get("/items")
def list_items(skip: int = 0, limit: int = 10, q: str | None = None):
return {"skip": skip, "limit": limit, "q": q}
/items works. /items?limit=50 works. /items?q=phone&skip=20&limit=5 works. The defaults make every parameter optional; remove the default to make one required.
A parameter typed as bool accepts true, false, 1, 0, yes, no, in any case. Repeated keys typed as a list become a list:
@app.get("/search")
def search(tag: list[str] = []):
return {"tags": tag} # /search?tag=python&tag=fastapi -> ["python", "fastapi"]
Request bodies with Pydantic
A function parameter typed as a Pydantic BaseModel is treated as a JSON body:
from pydantic import BaseModel
class Item(BaseModel):
name: str
price: float
in_stock: bool = True
@app.post("/items")
def create_item(item: Item):
return {"created": item.name, "price": item.price}
A POST to /items with { "name": "Phone", "price": 599.0 } becomes a fully validated Item instance inside the function. A request with the wrong type, missing field, or extra junk receives a 422 listing every problem. You wrote no validation code.
Mix body, path, and query in one function — FastAPI distinguishes them by position and type:
@app.put("/items/{item_id}")
def update_item(item_id: int, item: Item, notify: bool = False):
# item_id from the path, item from the body, notify from the query string
return {"item_id": item_id, "item": item.model_dump(), "notify": notify}
Pydantic v2 in 90 seconds
Pydantic v2 is the validation library powering FastAPI. A model is a class inheriting from BaseModel with typed fields:
from datetime import datetime
from pydantic import BaseModel
class User(BaseModel):
id: int
name: str
email: str
created_at: datetime
is_active: bool = True
What you get for free:
- Strict type coercion:
"42"becomes42,"2026-01-01T00:00:00Z"becomes adatetime. - Clear validation errors listing the field path and the problem.
model_dump()returns adict,model_dump_json()returns JSON text.User(**payload)validates a dict, raisingValidationErroron failure.
A model is immutable-ish but not frozen by default; you can opt into frozen-ness with model_config.
Field constraints
Field adds validation rules and OpenAPI metadata to a field. With Annotated, your type hint stays accurate:
from typing import Annotated
from pydantic import BaseModel, Field
class Item(BaseModel):
name: Annotated[str, Field(min_length=1, max_length=100)]
price: Annotated[float, Field(gt=0, le=1_000_000)]
tags: Annotated[list[str], Field(default_factory=list, max_length=10)]
gt, ge, lt, le constrain numbers. min_length, max_length, pattern constrain strings. default_factory runs once per instance, which is essential for mutable defaults like lists and dicts (never use tags: list[str] = [] directly — that shared list is a classic Python bug).
The constraints also flow through to the OpenAPI schema and Swagger UI.
Validators
For checks that need code, write a @field_validator or @model_validator:
from pydantic import BaseModel, field_validator
class SignUp(BaseModel):
username: str
password: str
@field_validator("username")
@classmethod
def no_spaces(cls, v: str) -> str:
if " " in v:
raise ValueError("username may not contain spaces")
return v
The validator runs after the basic type check. Raise ValueError to fail; return the (possibly transformed) value to pass.
Try it yourself. Create the SignUp model above and a POST /signup route. POST { "username": "ada lovelace", "password": "x" } and read the 422 response. Notice how the field path and message line up exactly with the validator.
Response models
Returning a Pydantic model from a path operation gives you typed, validated output:
class ItemOut(BaseModel):
id: int
name: str
price: float
@app.get("/items/{item_id}", response_model=ItemOut)
def read_item(item_id: int):
return {"id": item_id, "name": "Phone", "price": 599.0, "secret": "leak?"}
The response_model=ItemOut decorator tells FastAPI to filter whatever you return through ItemOut. The secret key in the return dict is silently dropped because it is not in the model. This is how you stop sensitive fields from leaking by accident.
Without response_model, FastAPI uses the function’s return type annotation. The decorator form wins when set, which lets you return a richer internal model and serialise a public-facing subset:
class ItemInternal(BaseModel):
id: int
name: str
price: float
cost: float # internal-only
class ItemPublic(BaseModel):
id: int
name: str
price: float
@app.get("/items/{item_id}", response_model=ItemPublic)
def read_item(item_id: int) -> ItemInternal:
return ItemInternal(id=item_id, name="Phone", price=599.0, cost=320.0)
The function returns a fully typed internal value; the wire response is the public subset. This split — Internal for what you compute, Public for what you send — scales well as APIs grow.
response_model_exclude_unset
A common PATCH endpoint problem: the caller sends a partial update and you want the response to reflect only what changed, not every default value.
class ItemUpdate(BaseModel):
name: str | None = None
price: float | None = None
in_stock: bool | None = None
@app.patch(
"/items/{item_id}",
response_model=ItemUpdate,
response_model_exclude_unset=True,
)
def update_item(item_id: int, patch: ItemUpdate):
return patch
response_model_exclude_unset=True tells FastAPI to omit fields that were never explicitly set on the returned model. PATCHing { "price": 499.0 } returns exactly { "price": 499.0 } rather than { "name": null, "price": 499.0, "in_stock": null }.
Three related flags exist for different filtering modes:
response_model_exclude_unset— drop fields the caller never setresponse_model_exclude_defaults— drop fields equal to their declared defaultresponse_model_exclude_none— drop fields whose value isNone
Pick whichever matches the API contract you want.
Status codes and responses
The default success status is 200. Override per route:
from fastapi import status
@app.post("/items", response_model=ItemOut, status_code=status.HTTP_201_CREATED)
def create_item(item: Item):
return {"id": 1, "name": item.name, "price": item.price}
For errors, raise HTTPException:
from fastapi import HTTPException
@app.get("/items/{item_id}", response_model=ItemOut)
def read_item(item_id: int):
if item_id < 1:
raise HTTPException(status_code=404, detail="Item not found")
return {"id": item_id, "name": "Phone", "price": 599.0}
The detail becomes the response body and is documented in OpenAPI.
A complete worked example
from typing import Annotated
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field
app = FastAPI()
# In-memory store for the example.
ITEMS: dict[int, "Item"] = {}
NEXT_ID = 1
class ItemCreate(BaseModel):
name: Annotated[str, Field(min_length=1, max_length=100)]
price: Annotated[float, Field(gt=0)]
in_stock: bool = True
class Item(BaseModel):
id: int
name: str
price: float
in_stock: bool
@app.post("/items", response_model=Item, status_code=status.HTTP_201_CREATED)
def create_item(payload: ItemCreate) -> Item:
global NEXT_ID
item = Item(id=NEXT_ID, **payload.model_dump())
ITEMS[item.id] = item
NEXT_ID += 1
return item
@app.get("/items", response_model=list[Item])
def list_items(in_stock: bool | None = None) -> list[Item]:
values = list(ITEMS.values())
if in_stock is not None:
values = [i for i in values if i.in_stock is in_stock]
return values
@app.get("/items/{item_id}", response_model=Item)
def read_item(item_id: int) -> Item:
item = ITEMS.get(item_id)
if item is None:
raise HTTPException(status_code=404, detail="Item not found")
return item
class ItemPatch(BaseModel):
name: str | None = None
price: Annotated[float, Field(gt=0)] | None = None
in_stock: bool | None = None
@app.patch(
"/items/{item_id}",
response_model=Item,
response_model_exclude_unset=False,
)
def patch_item(item_id: int, patch: ItemPatch) -> Item:
item = ITEMS.get(item_id)
if item is None:
raise HTTPException(status_code=404, detail="Item not found")
updated = item.model_copy(update=patch.model_dump(exclude_unset=True))
ITEMS[item_id] = updated
return updated
A clean split between an input model (ItemCreate), a stored/output model (Item), and a partial update model (ItemPatch). Each route is short, fully typed, and produces a complete OpenAPI spec at /docs.
Try it yourself. Run the example with fastapi dev main.py, open /docs, and POST a couple of items. Then PATCH one with { "price": 0 } and read the 422 — your gt=0 constraint catches it before your code runs.
Recap
You now know:
- Path operations are functions bound to an HTTP method and URL by decorators.
- Type hints on parameters drive both validation and OpenAPI docs — path, query, and body are distinguished by position and type.
- Pydantic v2
BaseModelis how FastAPI describes the shape of incoming and outgoing data. Field+Annotatedadd constraints (gt,min_length,pattern, etc.) without losing type accuracy.response_modelfilters the outgoing payload, including dropping unknown keys you accidentally return.response_model_exclude_unsetis the canonical pattern for PATCH-style responses.
Next steps
The next post moves from synchronous routes to async def path operations and FastAPI’s dependency injection system — the two features that make the framework feel different from older Python web frameworks.
→ Next: FastAPI Async Routes and Dependency Injection
Related: What Is FastAPI?.
Questions or feedback? Email codeloomdevv@gmail.com.