⚡ FastAPI Boot Camp — Part 2: Pydantic, Error Handling & Dependencies
Validation, Custom Errors & Dependency Injection
Goal: Go beyond basic models. Know how to validate complex data, raise meaningful errors, and share logic cleanly across routes using dependency injection.
1. Pydantic Deep Dive
Field-level validation with Field()
from pydantic import BaseModel, Field
from typing import Optional
class Product(BaseModel):
name: str = Field(min_length=1, max_length=100, description="Product name")
price: float = Field(gt=0, description="Must be positive")
stock: int = Field(ge=0, default=0, description="Cannot be negative")
sku: Optional[str] = Field(default=None, pattern=r"^[A-Z]{3}-\d{4}$")
# sku must match pattern like "ABC-1234" if provided
# Valid: Product(name="Widget", price=9.99, sku="WDG-0001")
# Invalid: Product(name="Widget", price=-1) → validation error: price must be > 0
Validators — custom logic on fields
from pydantic import BaseModel, field_validator, model_validator
class Order(BaseModel):
username: str
quantity: int
discount: float = 0.0
@field_validator("username")
@classmethod
def username_must_be_lowercase(cls, v):
if v != v.lower():
raise ValueError("username must be lowercase")
return v
@field_validator("discount")
@classmethod
def discount_must_be_valid(cls, v):
if not 0 <= v <= 1:
raise ValueError("discount must be between 0 and 1")
return v
# Cross-field validation (both fields available)
@model_validator(mode="after")
def check_bulk_discount(self):
if self.quantity < 10 and self.discount > 0.2:
raise ValueError("Discount over 20% only available for orders of 10+")
return self
Nested models
from typing import List
class Address(BaseModel):
street: str
city: str
zip_code: str = Field(pattern=r"^\d{5}$")
class Customer(BaseModel):
name: str
email: str
address: Address # nested model — 05-FastAPI handles the nested JSON automatically
tags: List[str] = [] # list of strings
# Request body:
# {
# "name": "Alice",
# "email": "alice@example.com",
# "address": {"street": "123 Main St", "city": "Seattle", "zip_code": "98101"},
# "tags": ["vip", "wholesale"]
# }
model_dump() and model_dump(exclude_unset=True)
@app.patch("/items/{item_id}")
def partial_update(item_id: int, item: Item):
# exclude_unset=True — only fields the client actually sent
# This is critical for PATCH endpoints (partial update)
update_data = item.model_dump(exclude_unset=True)
# Now update_data only has the fields that were included in the request
# If client sent {"price": 19.99}, update_data = {"price": 19.99}
# NOT {"price": 19.99, "name": None, "description": None}
existing_item = get_item_from_db(item_id)
updated = existing_item.model_copy(update=update_data)
return updated
Interview line: "For PATCH endpoints, I always use
exclude_unset=True— otherwise default values overwrite fields the client didn't intend to change."
2. Error Handling
Raising HTTP exceptions
from fastapi import FastAPI, HTTPException, status
fake_db = {1: "Widget", 2: "Gadget"}
@app.get("/items/{item_id}")
def get_item(item_id: int):
if item_id not in fake_db:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Item {item_id} not found"
)
return {"item": fake_db[item_id]}
# GET /items/99 → 404 {"detail": "Item 99 not found"}
Custom exception handlers — app-wide error handling
from fastapi import Request
from fastapi.responses import JSONResponse
class InsufficientStockError(Exception):
def __init__(self, item_id: int, available: int):
self.item_id = item_id
self.available = available
@app.exception_handler(InsufficientStockError)
async def stock_exception_handler(request: Request, exc: InsufficientStockError):
return JSONResponse(
status_code=409, # Conflict
content={
"error": "insufficient_stock",
"item_id": exc.item_id,
"available": exc.available,
"message": f"Only {exc.available} units available"
}
)
@app.post("/orders/")
def create_order(item_id: int, quantity: int):
stock = get_stock(item_id)
if quantity > stock:
raise InsufficientStockError(item_id=item_id, available=stock)
# ... process order
Overriding the default validation error handler
from fastapi.exceptions import RequestValidationError
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
# Custom format for 422 errors — useful for client-friendly error messages
errors = []
for error in exc.errors():
errors.append({
"field": " → ".join(str(loc) for loc in error["loc"]),
"message": error["msg"],
"type": error["type"]
})
return JSONResponse(status_code=422, content={"validation_errors": errors})
3. Dependency Injection
FastAPI's Depends() is one of its most powerful features. Use it to share logic across routes without repetition.
Simple dependency — common query params
from fastapi import Depends
# Define reusable logic as a function
def pagination_params(skip: int = 0, limit: int = Query(default=10, le=100)):
return {"skip": skip, "limit": limit}
# Inject into any route
@app.get("/items/")
def list_items(pagination: dict = Depends(pagination_params)):
return {"pagination": pagination, "items": get_items(**pagination)}
@app.get("/users/")
def list_users(pagination: dict = Depends(pagination_params)):
return {"pagination": pagination, "users": get_users(**pagination)}
# Both routes get ?skip= and ?limit= validation for free
Class-based dependencies
class CommonQueryParams:
def __init__(self, q: Optional[str] = None, skip: int = 0, limit: int = 100):
self.q = q
self.skip = skip
self.limit = limit
@app.get("/items/")
def list_items(commons: CommonQueryParams = Depends(CommonQueryParams)):
# Shorthand: Depends() with no args infers the class
# commons.q, commons.skip, commons.limit are all available
return {"q": commons.q, "skip": commons.skip}
Authentication dependency — the real-world pattern
from fastapi.security import OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
def get_current_user(token: str = Depends(oauth2_scheme)):
user = decode_token(token) # your token validation logic
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
return user
def require_admin(current_user = Depends(get_current_user)):
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
return current_user
# Protect specific routes
@app.get("/admin/stats")
def admin_stats(admin = Depends(require_admin)): # chains: token → user → admin check
return get_stats()
@app.get("/profile")
def my_profile(user = Depends(get_current_user)): # only needs auth, not admin
return user
Interview line: "Dependency injection in FastAPI lets me write auth, pagination, and DB session logic once and compose it across routes — no decorators, no middleware spaghetti."
Database session dependency — the standard pattern
from sqlalchemy.orm import Session
from database import SessionLocal # your DB setup
def get_db():
db = SessionLocal()
try:
yield db # generator — gives db to route, then runs cleanup
finally:
db.close() # always closes, even on exception
@app.get("/items/{item_id}")
def get_item(item_id: int, db: Session = Depends(get_db)):
item = db.query(Item).filter(Item.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Not found")
return item
4. Background Tasks
Run work after returning the response — useful for sending emails, logging, etc.
from fastapi import BackgroundTasks
def send_notification_email(email: str, message: str):
# Simulating a slow operation (email sending)
time.sleep(2)
print(f"Email sent to {email}: {message}")
@app.post("/users/{user_id}/notify")
def notify_user(
user_id: int,
background_tasks: BackgroundTasks
):
user = get_user(user_id)
background_tasks.add_task(
send_notification_email,
email=user.email,
message="Your order has shipped!"
)
return {"message": "Notification queued"} # Returns immediately, email sends after
✅ Part 2 Checklist
- [ ] Can add field validation with
Field(gt=, min_length=, pattern=) - [ ] Know
@field_validatorfor single-field logic and@model_validatorfor cross-field - [ ] Know
exclude_unset=Truefor PATCH endpoints — can explain why it matters - [ ] Can raise
HTTPExceptionwith correct status codes - [ ] Can write a custom
@app.exception_handler - [ ] Understand
Depends()— can write a reusable auth dependency - [ ] Know the
yieldDB session pattern — whyfinallymatters there
Next up → Part 3: Async, Middleware, Routers & Production Patterns**