A reference guide covering key FastAPI/Pydantic patterns used in the Employee Directory API solution.
In the Flask solution, we use Python's built-in dataclass:
from dataclasses import dataclass, asdict
@dataclass
class Employee:
id: str
name: str
email: str
department: str
title: str
manager_id: Optional[str] = NoneIn the FastAPI solution, we use Pydantic's BaseModel:
from pydantic import BaseModel
class Employee(BaseModel):
id: str
name: str
email: str
department: str
title: str
manager_id: Optional[str] = NoneThey look similar but serve different purposes:
Dataclass is a convenience for defining classes that primarily store data. It auto-generates __init__, __repr__, and __eq__ methods. However, it performs no validation at runtime — if you pass an integer where a string is expected, Python won't complain:
emp = Employee(id=123, name="Sarah", ...) # No error — 123 is stored as-isTo serialize a dataclass to a dictionary, you use the asdict() helper function.
BaseModel does everything a dataclass does, plus:
- Runtime validation: Pydantic validates and coerces types when a model is instantiated. If a field expects
strand receives123, Pydantic will coerce it to"123". If it receives a value that can't be coerced, it raises aValidationError. - Serialization built-in: Methods like
.model_dump()(to dict) and.model_json_schema()(to JSON Schema) are built into the class. - FastAPI integration: When a BaseModel is used as a route parameter, FastAPI automatically parses the request body, validates it, and returns a 422 response with detailed error messages if validation fails. This is why the FastAPI solution doesn't need manual "missing required fields" checks — Pydantic handles it.
# Flask — manual validation
data = request.get_json()
required_fields = ['name', 'email', 'department', 'title']
missing_fields = [f for f in required_fields if f not in data]
if missing_fields:
return jsonify({'error': 'Missing required fields', 'missing': missing_fields}), 400
# FastAPI — Pydantic handles it automatically
@app.post("/employees", status_code=201)
def create_employee(data: EmployeeCreate): # Pydantic validates before this runs
...Why we use separate models in FastAPI (EmployeeCreate, EmployeeUpdate, Employee):
EmployeeCreate— all fields required exceptmanager_id(for POST)EmployeeUpdate— all fields optional (for PATCH)Employee— includesid(for responses)
This pattern avoids a single model trying to serve all purposes, which would require making fields optional that should be required on creation.
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: runs BEFORE the app starts accepting requests
seed_data()
print(f"Seeded {len(employees_db)} employees")
yield
# Shutdown: runs AFTER the app stops accepting requests
# (cleanup code would go here)
app = FastAPI(title="Employee Directory API", lifespan=lifespan)The yield keyword splits the function into two phases:
- Everything before
yieldruns during startup (before the server accepts requests) - Everything after
yieldruns during shutdown (after the server stops accepting requests)
Think of it like a sandwich — the app's entire lifetime happens at the yield point:
seed_data() ← startup code
yield ← app runs here, serving requests...
cleanup_resources() ← shutdown code (when app stops)
The @asynccontextmanager decorator is the async version of Python's @contextmanager. FastAPI is built on ASGI (Asynchronous Server Gateway Interface), so its lifecycle hooks are async. Even though our seed_data() function is synchronous, the lifespan function itself must be async because that's what FastAPI's interface expects.
The older FastAPI pattern used event decorators:
# Deprecated — don't use
@app.on_event("startup")
async def startup():
seed_data()
@app.on_event("shutdown")
async def shutdown():
cleanup()The lifespan approach replaced this because it allows sharing state between startup and shutdown (e.g., opening a database connection before yield and closing it after), and it's a single function rather than two disconnected ones.
from fastapi import Query
@app.get("/employees")
def list_employees(
department: Optional[str] = Query(None, description="Filter by department"),
q: Optional[str] = Query(None, description="Search employee names"),
page: int = Query(1, ge=1, description="Page number"),
per_page: Optional[int] = Query(None, ge=1, le=100, description="Items per page"),
):Query tells FastAPI that a parameter comes from the URL's query string (the ?key=value part). You could omit Query entirely and FastAPI would still treat these as query parameters based on their position in the function signature. But Query adds:
- A default value — the first argument (e.g.,
Query(None)means optional,Query(1)means defaults to 1) - Validation constraints — parameters like
ge,le,gt,lt - OpenAPI metadata —
descriptionappears in the auto-generated Swagger docs at/docs
These are numeric validators borrowed from Pydantic's constraint vocabulary:
| Parameter | Meaning | Mnemonic |
|---|---|---|
ge |
greater than or equal to (>=) | ge=1 means value must be >= 1 |
le |
less than or equal to (<=) | le=100 means value must be <= 100 |
gt |
greater than (>) | gt=0 means value must be > 0 |
lt |
less than (<) | lt=101 means value must be < 101 |
So per_page: Optional[int] = Query(None, ge=1, le=100) means:
- It's optional (default is
None) - If provided, it must be an integer between 1 and 100 inclusive
- FastAPI returns a 422 with a clear error message if the constraint is violated
Compare this to the Flask solution where we validate manually:
# Flask — manual clamping (silently corrects bad input)
per_page = min(max(1, per_page), 100)
# FastAPI — Query validation (rejects bad input with 422)
per_page: Optional[int] = Query(None, ge=1, le=100)The Flask approach silently clamps values (requesting per_page=500 gives you 100). The FastAPI approach rejects invalid values outright with a validation error. Both are valid design choices — silent correction is more forgiving, explicit rejection is more transparent.
FastAPI also provides Path and Body classes that work identically to Query but for path parameters and request body fields, respectively.
These are Pydantic v2 methods on any BaseModel instance.
Converts a Pydantic model to a plain dictionary.
emp = Employee(id="emp-001", name="Sarah", email="sarah@cascade.ai",
department="Engineering", title="VP of Engineering")
emp.model_dump()
# {'id': 'emp-001', 'name': 'Sarah', 'email': 'sarah@cascade.ai',
# 'department': 'Engineering', 'title': 'VP of Engineering', 'manager_id': None}This is the Pydantic v2 equivalent of asdict() for dataclasses.
The exclude_unset parameter is the key pattern for PATCH endpoints:
data = EmployeeUpdate(title="Staff Engineer")
data.model_dump()
# {'name': None, 'email': None, 'department': None, 'title': 'Staff Engineer', 'manager_id': None}
data.model_dump(exclude_unset=True)
# {'title': 'Staff Engineer'}Without exclude_unset=True, every field appears in the dict — including ones the client never sent, which would overwrite existing values with None. With exclude_unset=True, only fields the client explicitly included in the request body are returned. This is what makes true partial updates possible.
Pydantic tracks which fields were explicitly set during instantiation vs which used their default values, so it can distinguish between "client sent manager_id: null" and "client didn't send manager_id at all" — a distinction that's impossible with plain dictionaries.
Pydantic v1 equivalent: The older API used .dict(exclude_unset=True). If a candidate uses .dict(), it still works but indicates they learned from older tutorials or documentation.
Creates a shallow copy of a model with optional field overrides.
emp = Employee(id="emp-001", name="Sarah", email="sarah@cascade.ai",
department="Engineering", title="VP of Engineering")
updated = emp.model_copy(update={"title": "CTO", "department": "Executive"})
# updated is a new Employee with title="CTO" and department="Executive"
# emp is unchangedThe update parameter is a dictionary of fields to override in the copy. This is how the FastAPI solution applies partial updates:
update_data = data.model_dump(exclude_unset=True) # Only fields the client sent
updated = emp.model_copy(update=update_data) # New model with those fields changed
employees_db[employee_id] = updated # Replace in storageCompare this to the Flask solution which mutates the dataclass in place:
# Flask — mutates the existing object
for field in ['name', 'email', 'department', 'title', 'manager_id']:
if field in data:
setattr(emp, field, data[field])The model_copy approach is more idiomatic for Pydantic because BaseModel instances are conventionally treated as immutable (even though they technically aren't in v2). Creating a new instance rather than mutating also means validation runs on the updated data.
Pydantic v1 equivalent: The older API used .copy(update={...}).