Learn Python Series (#50) - FastAPI Advanced - Validation & Dependencies

Repository
What will I learn
- You will learn how to build complex validation rules beyond basic type checking;
- how dependency injection works in FastAPI and why it's the framework's most powerful feature;
- how to compose dependencies for reusable authentication, pagination, and database access;
- advanced Pydantic v2 features: field validators, model validators, computed fields;
- how to handle file uploads, form data, and custom error responses.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Python 3(.11+) distribution, such as (for example) the Anaconda Distribution;
- The ambition to learn Python programming.
Difficulty
- Intermediate, advanced
Curriculum (of the Learn Python Series):
- Learn Python Series - Intro
- Learn Python Series (#2) - Handling Strings Part 1
- Learn Python Series (#3) - Handling Strings Part 2
- Learn Python Series (#4) - Round-Up #1
- Learn Python Series (#5) - Handling Lists Part 1
- Learn Python Series (#6) - Handling Lists Part 2
- Learn Python Series (#7) - Handling Dictionaries
- Learn Python Series (#8) - Handling Tuples
- Learn Python Series (#9) - Using Import
- Learn Python Series (#10) - Matplotlib Part 1
- Learn Python Series (#11) - NumPy Part 1
- Learn Python Series (#12) - Handling Files
- Learn Python Series (#13) - Mini Project - Developing a Web Crawler Part 1
- Learn Python Series (#14) - Mini Project - Developing a Web Crawler Part 2
- Learn Python Series (#15) - Handling JSON
- Learn Python Series (#16) - Mini Project - Developing a Web Crawler Part 3
- Learn Python Series (#17) - Roundup #2 - Combining and analyzing any-to-any multi-currency historical data
- Learn Python Series (#18) - PyMongo Part 1
- Learn Python Series (#19) - PyMongo Part 2
- Learn Python Series (#20) - PyMongo Part 3
- Learn Python Series (#21) - Handling Dates and Time Part 1
- Learn Python Series (#22) - Handling Dates and Time Part 2
- Learn Python Series (#23) - Handling Regular Expressions Part 1
- Learn Python Series (#24) - Handling Regular Expressions Part 2
- Learn Python Series (#25) - Handling Regular Expressions Part 3
- Learn Python Series (#26) - pipenv & Visual Studio Code
- Learn Python Series (#27) - Handling Strings Part 3 (F-Strings)
- Learn Python Series (#28) - Using Pickle and Shelve
- Learn Python Series (#29) - Handling CSV
- Learn Python Series (#30) - Data Science Part 1 - Pandas
- Learn Python Series (#31) - Data Science Part 2 - Pandas
- Learn Python Series (#32) - Data Science Part 3 - Pandas
- Learn Python Series (#33) - Data Science Part 4 - Pandas
- Learn Python Series (#34) - Working with APIs in 2026: What's Changed
- Learn Python Series (#35) - Working with APIs Part 2: Beyond GET Requests
- Learn Python Series (#36) - Type Hints and Modern Python
- Learn Python Series (#37) - Virtual Environments and Dependency Management
- Learn Python Series (#38) - Testing Your Code Part 1
- Learn Python Series (#39) - Testing Your Code Part 2
- Learn Python Series (#40) - Asynchronous Python Part 1
- Learn Python Series (#41) - Asynchronous Python Part 2
- Learn Python Series (#42) - Building CLI Applications
- Learn Python Series (#43) - Mini Project - Crypto Price Tracker
- Learn Python Series (#44) - Context Managers & Decorators Deep Dive
- Learn Python Series (#45) - Metaclasses & Class Design Patterns
- Learn Python Series (#46) - Descriptors & Properties
- Learn Python Series (#47) - Generators & Iterators Advanced
- Learn Python Series (#48) - Concurrency - Threading vs Multiprocessing
- Learn Python Series (#49) - FastAPI Basics - Modern Web APIs
- Learn Python Series (#50) - FastAPI Advanced - Validation & Dependencies (this post)
GitHub Account
Learn Python Series (#50) - FastAPI Advanced - Validation & Dependencies
Last episode we got FastAPI up and running — routes, type validation, Pydantic models, auto-docs. That was the "Hello World" tier. Now let's build something real.
Real APIs need: passwords that meet complexity rules, email addresses that are actually valid, request data that passes business logic checks (not just type checks), shared authentication across dozens of endpoints, database sessions that open and close cleanly, and error responses that follow a consistent format. FastAPI handles all of this through two features: Pydantic validators and dependency injection.
Nota bene: "Dependency injection" sounds enterprise-Java-scary, but in FastAPI it's remarkably elegant. Think of it as "functions that run automatically before your endpoint, providing whatever it needs." Database connection? Dependency. Current authenticated user? Dependency. Rate limiter? Dependency. Pagination parameters? Dependency. Once this mental model clicks, you'll structure every serious API around it ;-)
Custom validators with Pydantic v2
In episode #49, we used Pydantic's built-in constraints (Field(gt=0, max_length=100)). But real-world validation often needs custom logic — password complexity rules, cross-field validation, data normalization. Pydantic v2 provides field_validator and model_validator for this:
from pydantic import BaseModel, Field, field_validator, model_validator
import re
class UserRegistration(BaseModel):
username: str = Field(..., min_length=3, max_length=30)
email: str
password: str = Field(..., min_length=8)
password_confirm: str
age: int = Field(..., ge=13) # Minimum age 13
@field_validator('username')
@classmethod
def username_must_be_alphanumeric(cls, v):
if not re.match(r'^[a-zA-Z][a-zA-Z0-9_]*$', v):
raise ValueError(
'Username must start with a letter and contain '
'only letters, numbers, and underscores'
)
return v.lower() # Normalize to lowercase
@field_validator('email')
@classmethod
def email_must_be_valid(cls, v):
# Basic email validation (use email-validator package for production)
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(pattern, v):
raise ValueError('Invalid email address format')
return v.lower()
@field_validator('password')
@classmethod
def password_complexity(cls, v):
if not re.search(r'[A-Z]', v):
raise ValueError('Password must contain at least one uppercase letter')
if not re.search(r'[a-z]', v):
raise ValueError('Password must contain at least one lowercase letter')
if not re.search(r'[0-9]', v):
raise ValueError('Password must contain at least one digit')
return v
@model_validator(mode='after')
def passwords_must_match(self):
if self.password != self.password_confirm:
raise ValueError('Passwords do not match')
return self
Several things to notice. @field_validator runs on individual fields — it receives the raw value and returns the (possibly transformed) value. You can normalize data here (lowercasing usernames, stripping whitespace). The @classmethod decorator is required in Pydantic v2.
@model_validator(mode='after') runs after all individual field validators have passed. It receives the fully constructed model instance, so you can check relationships between fields — like ensuring password and password_confirm match, or that end_date is after start_date.
If you need to validate before individual fields are processed, use mode='before':
class FlexibleInput(BaseModel):
values: list[int]
@model_validator(mode='before')
@classmethod
def coerce_single_to_list(cls, data):
"""Accept a single int or a list of ints."""
if isinstance(data, dict) and isinstance(data.get('values'), int):
data['values'] = [data['values']]
return data
This before validator sees the raw input data (a dict) before Pydantic tries to parse it. Useful for accepting flexible input formats.
Reusable validators
When multiple models need the same validation logic, extract it into reusable functions:
from pydantic import field_validator
from typing import Any
def validate_not_empty(v: str) -> str:
"""Strip whitespace and reject empty strings."""
v = v.strip()
if not v:
raise ValueError('Field cannot be empty or whitespace-only')
return v
def validate_positive(v: int | float) -> int | float:
"""Reject non-positive numbers."""
if v <= 0:
raise ValueError('Must be a positive number')
return v
class Product(BaseModel):
name: str
description: str
price: float
quantity: int
# Apply reusable validators to specific fields
_validate_name = field_validator('name')(validate_not_empty)
_validate_description = field_validator('description')(validate_not_empty)
_validate_price = field_validator('price')(validate_positive)
_validate_quantity = field_validator('quantity')(validate_positive)
class Order(BaseModel):
customer_name: str
total: float
_validate_name = field_validator('customer_name')(validate_not_empty)
_validate_total = field_validator('total')(validate_positive)
Same validation logic, used across multiple models, zero code duplication. The functional approach (field_validator('field')(func)) might look unusual, but it's just calling the decorator manually instead of using @ syntax.
Computed fields with Pydantic v2
Sometimes you want fields that are derived from other fields — not stored, but calculated:
from pydantic import BaseModel, computed_field
class CartItem(BaseModel):
name: str
unit_price: float
quantity: int
tax_rate: float = 0.21 # 21% VAT (Dutch standard)
@computed_field
@property
def subtotal(self) -> float:
return round(self.unit_price * self.quantity, 2)
@computed_field
@property
def tax_amount(self) -> float:
return round(self.subtotal * self.tax_rate, 2)
@computed_field
@property
def total(self) -> float:
return round(self.subtotal + self.tax_amount, 2)
item = CartItem(name="Widget", unit_price=9.99, quantity=3)
print(item.model_dump())
# {'name': 'Widget', 'unit_price': 9.99, 'quantity': 3,
# 'tax_rate': 0.21, 'subtotal': 29.97, 'tax_amount': 6.29, 'total': 36.26}
Computed fields appear in serialization output (JSON responses, model_dump()) but are never accepted as input. They're pure derived values. This is much cleaner than calculating them in your endpoint code.
Advanced parameter validation: Query, Path, Header, Cookie
FastAPI provides specialized validators for each parameter source:
from fastapi import Query, Path, Header, Cookie
@app.get("/search/")
def search(
q: str = Query(
...,
min_length=2,
max_length=100,
description="Search query",
examples=["python fastapi", "web framework"],
),
page: int = Query(1, ge=1, le=1000, alias="p"),
per_page: int = Query(20, ge=1, le=100),
sort_by: str = Query("relevance", pattern="^(relevance|date|rating)$"),
):
return {
"query": q,
"page": page,
"per_page": per_page,
"sort_by": sort_by,
}
The alias="p" is subtle but powerful — clients send ?p=5 but your code reads page. The pattern parameter applies a regex constraint, so sort_by only accepts "relevance", "date", or "rating". And the examples show up in the OpenAPI docs.
For headers and cookies:
@app.get("/trace/")
def trace_request(
user_agent: str = Header(..., alias="User-Agent"),
session_id: str | None = Cookie(None),
x_request_id: str = Header(...),
):
return {
"user_agent": user_agent,
"session_id": session_id,
"request_id": x_request_id,
}
Note: HTTP headers use hyphens (X-Request-Id), but Python variables use underscores (x_request_id). FastAPI converts automatically. The alias override is needed for non-standard casing like User-Agent.
Dependency injection: the core pattern
Dependencies in FastAPI are just functions (or callables) that your endpoint declares it needs. FastAPI calls them automatically before the endpoint runs:
from fastapi import Depends
def get_pagination(
skip: int = Query(0, ge=0, description="Records to skip"),
limit: int = Query(20, ge=1, le=100, description="Records per page"),
):
return {"skip": skip, "limit": limit}
@app.get("/items/")
def list_items(pagination: dict = Depends(get_pagination)):
# pagination = {"skip": 0, "limit": 20} (from query params)
all_items = list(range(100))
return {
"items": all_items[pagination["skip"]:pagination["skip"] + pagination["limit"]],
"pagination": pagination,
}
@app.get("/users/")
def list_users(pagination: dict = Depends(get_pagination)):
# Same pagination logic, reused
return {"users": [], "pagination": pagination}
FastAPI sees Depends(get_pagination), calls get_pagination with the request's query parameters, and passes the result to list_items as pagination. The dependency's parameters (skip, limit) are automatically extracted from the request — they appear in the OpenAPI docs, get validated, and have defaults. You get reusable, self-documenting request handling.
Class-based dependencies
For dependencies that need configuration or state, use callable classes:
class Paginator:
def __init__(self, max_limit: int = 100):
self.max_limit = max_limit
def __call__(
self,
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1),
):
# Enforce max_limit regardless of what client requests
effective_limit = min(limit, self.max_limit)
return {"skip": skip, "limit": effective_limit}
# Different paginators for different endpoints
standard_paginator = Paginator(max_limit=100)
limited_paginator = Paginator(max_limit=10)
@app.get("/items/")
def list_items(pagination: dict = Depends(standard_paginator)):
return {"pagination": pagination}
@app.get("/expensive-resource/")
def list_expensive(pagination: dict = Depends(limited_paginator)):
return {"pagination": pagination} # Max 10 per page
The __call__ method makes the class instance callable. FastAPI treats it like any other dependency function. The instance holds configuration (max_limit), while each request gets its own __call__ invocation.
Dependency chains: composing complex logic
This is where dependency injection becomes truly powerful. Dependencies can depend on other dependencies, and FastAPI resolves the entire graph:
from fastapi import Header, HTTPException, Depends
# Layer 1: Extract token from header
def get_token(authorization: str = Header(...)):
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Invalid auth scheme")
return authorization[7:] # Strip "Bearer "
# Layer 2: Decode token into user data
def get_current_user(token: str = Depends(get_token)):
# In reality: decode JWT, query database
users_db = {
"valid-token-abc": {"id": 1, "username": "scipio", "role": "admin"},
"valid-token-xyz": {"id": 2, "username": "reader", "role": "viewer"},
}
user = users_db.get(token)
if not user:
raise HTTPException(status_code=401, detail="Invalid or expired token")
return user
# Layer 3: Require specific role
def require_role(required_role: str):
def dependency(user: dict = Depends(get_current_user)):
if user["role"] != required_role:
raise HTTPException(
status_code=403,
detail=f"Requires {required_role} role, you have {user['role']}"
)
return user
return dependency
# Usage: endpoints declare exactly what access level they need
@app.get("/profile/")
def read_profile(user: dict = Depends(get_current_user)):
return {"user": user}
@app.delete("/users/{user_id}")
def delete_user(user_id: int, admin: dict = Depends(require_role("admin"))):
return {"deleted": user_id, "by": admin["username"]}
@app.get("/dashboard/")
def admin_dashboard(admin: dict = Depends(require_role("admin"))):
return {"stats": {"users": 42, "items": 100}}
The dependency chain flows: require_role("admin") → get_current_user → get_token → extracts from Authorization header. FastAPI resolves this automatically. If any layer fails (invalid token, wrong role), the appropriate HTTP error is returned and the endpoint never runs.
Notice require_role is a factory function — it returns a dependency function parameterized by the required role. This pattern (a function that returns a dependency) is how you create configurable dependencies.
Dependencies with yield: setup and teardown
For resources that need cleanup (database connections, file handles, external service clients), use yield in your dependency:
def get_db():
"""Provide a database session, ensure cleanup."""
db = SessionLocal() # Open connection
try:
yield db # Provide to endpoint
finally:
db.close() # Always close, even on error
def get_http_client():
"""Provide an HTTP client for external API calls."""
import httpx
client = httpx.Client(timeout=10.0)
try:
yield client
finally:
client.close()
@app.get("/users/")
def list_users(db = Depends(get_db)):
return db.query("SELECT * FROM users")
@app.get("/external-data/")
def get_external(client = Depends(get_http_client)):
response = client.get("https://api.example.com/data")
return response.json()
The code before yield is the setup (open connection). The code after yield (in finally) is the teardown (close connection). This runs even if the endpoint raises an exception — exactly like context managers (which we covered in episode #44).
In fact, this IS a context manager pattern integrated into FastAPI's dependency system. The beauty is that your endpoint function doesn't need to know about setup/teardown at all — it just receives db and uses it. Separation of concerns.
Global and router-level dependencies
Apply dependencies to all endpoints at once:
# Apply to entire application
app = FastAPI(dependencies=[Depends(verify_api_key)])
# Apply to a specific router
from fastapi import APIRouter
admin_router = APIRouter(
prefix="/admin",
tags=["admin"],
dependencies=[Depends(require_role("admin"))],
)
@admin_router.get("/stats")
def admin_stats():
# require_role("admin") already ran — no need to declare it again
return {"total_users": 42}
@admin_router.post("/broadcast")
def send_broadcast(message: str):
return {"sent": True, "message": message}
app.include_router(admin_router)
Every endpoint under /admin/ automatically requires admin authentication. The individual endpoint functions don't even mention authentication — it's handled at the router level.
File uploads
FastAPI handles file uploads through UploadFile, which is memory-efficient (large files are spooled to disk, not loaded entirely into memory):
from fastapi import File, UploadFile, HTTPException
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
MAX_SIZE = 5 * 1024 * 1024 # 5 MB
@app.post("/upload/avatar")
async def upload_avatar(
file: UploadFile = File(..., description="Profile picture (max 5MB)"),
user: dict = Depends(get_current_user),
):
# Validate content type
if file.content_type not in ALLOWED_TYPES:
raise HTTPException(
status_code=415,
detail=f"Unsupported file type: {file.content_type}. "
f"Allowed: {', '.join(ALLOWED_TYPES)}"
)
# Read file content
contents = await file.read()
# Validate file size
if len(contents) > MAX_SIZE:
raise HTTPException(
status_code=413,
detail=f"File too large: {len(contents)} bytes. Max: {MAX_SIZE}"
)
# In reality: save to disk or cloud storage
return {
"filename": file.filename,
"content_type": file.content_type,
"size_bytes": len(contents),
"user": user["username"],
}
@app.post("/upload/documents")
async def upload_documents(files: list[UploadFile] = File(...)):
results = []
for f in files:
content = await f.read()
results.append({
"filename": f.filename,
"size": len(content),
})
return {"uploaded": len(results), "files": results}
UploadFile provides async methods (read(), write(), seek(), close()) because file I/O can be slow. The await file.read() doesn't block the event loop while reading large uploads.
Form data
For HTML form submissions (content type application/x-www-form-urlencoded instead of application/json):
from fastapi import Form
@app.post("/login/")
def login(
username: str = Form(..., min_length=3),
password: str = Form(..., min_length=8),
remember_me: bool = Form(False),
):
# In reality: verify credentials against database
return {
"username": username,
"remember_me": remember_me,
"message": "Login successful",
}
Important: you cannot mix Form and JSON Body in the same endpoint. A request is either form-encoded or JSON-encoded — not both. This is an HTTP protocol constraint, not a FastAPI limitation.
You can combine Form and File in the same endpoint though (for multipart form submissions):
@app.post("/submit-report/")
async def submit_report(
title: str = Form(...),
description: str = Form(...),
attachment: UploadFile = File(None), # Optional file
):
result = {"title": title, "description": description}
if attachment:
content = await attachment.read()
result["attachment_size"] = len(content)
return result
Custom error responses
By default, FastAPI returns validation errors in Pydantic's format. You can customize this globally:
from fastapi import Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
):
"""Custom format for validation errors."""
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={
"error": "validation_error",
"detail": f"{len(errors)} validation error(s)",
"errors": errors,
},
)
Now every validation error across your entire API uses this consistent format. Your API consumers get predictable error responses they can parse programmatically.
Testing with dependency overrides
One of dependency injection's biggest benefits: in tests, you can swap real dependencies for fakes:
from fastapi.testclient import TestClient
# Real dependency: queries actual database
def get_db():
db = RealDatabase()
try:
yield db
finally:
db.close()
# Test override: uses in-memory fake
def get_fake_db():
return FakeDatabase({"users": [{"id": 1, "name": "Test User"}]})
# Override for testing
app.dependency_overrides[get_db] = get_fake_db
client = TestClient(app)
def test_list_users():
response = client.get("/users/")
assert response.status_code == 200
assert len(response.json()["users"]) == 1
# Override auth for testing protected endpoints
def fake_current_user():
return {"id": 1, "username": "test", "role": "admin"}
app.dependency_overrides[get_current_user] = fake_current_user
def test_admin_endpoint():
response = client.get("/admin/stats")
assert response.status_code == 200
# Clean up overrides after testing
app.dependency_overrides.clear()
No mocking libraries needed. No monkey-patching. Just provide a different function with the same return type. This is one of the clearest testing patterns I've ever used — and it works because dependencies are functions with explicit contracts, not hidden global state.
Putting it all together: a mini CRUD API
Let me show how all these pieces compose into a realistic (if small) application:
from fastapi import FastAPI, Depends, HTTPException, Query
from pydantic import BaseModel, Field, field_validator
from datetime import datetime
app = FastAPI(title="Bookshelf API")
# --- Models ---
class BookCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
author: str = Field(..., min_length=1, max_length=100)
isbn: str = Field(..., pattern=r'^\d{13}$')
year: int = Field(..., ge=1450, le=datetime.now().year)
@field_validator('title', 'author')
@classmethod
def strip_whitespace(cls, v):
return v.strip()
class BookResponse(BookCreate):
id: int
created_at: datetime
# --- Database (in-memory for demo) ---
books_db: dict[int, dict] = {}
next_id = 1
# --- Dependencies ---
def get_book_or_404(book_id: int):
if book_id not in books_db:
raise HTTPException(status_code=404, detail=f"Book {book_id} not found")
return books_db[book_id]
# --- Endpoints ---
@app.post("/books/", response_model=BookResponse, status_code=201)
def create_book(book: BookCreate):
global next_id
db_book = {**book.model_dump(), "id": next_id, "created_at": datetime.now()}
books_db[next_id] = db_book
next_id += 1
return db_book
@app.get("/books/", response_model=list[BookResponse])
def list_books(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
author: str | None = Query(None, min_length=2),
):
results = list(books_db.values())
if author:
results = [b for b in results if author.lower() in b["author"].lower()]
return results[skip:skip + limit]
@app.get("/books/{book_id}", response_model=BookResponse)
def get_book(book: dict = Depends(get_book_or_404)):
return book
@app.delete("/books/{book_id}", status_code=204)
def delete_book(book_id: int, book: dict = Depends(get_book_or_404)):
del books_db[book_id]
Notice: get_book_or_404 is a dependency that both validates the book exists AND returns it. The get_book and delete_book endpoints both use it, so the "not found" logic is written once. The BookCreate model handles all input validation. The BookResponse model controls what gets returned. And all of this generates complete OpenAPI documentation at /docs.
Oké, samengevat
In this episode, we explored advanced FastAPI validation and dependency injection:
@field_validatorenables custom validation logic per field with data normalization@model_validatorvalidates relationships between fields (password confirmation, date ranges)- Reusable validators can be shared across multiple models as plain functions
@computed_fieldadds derived read-only fields to Pydantic modelsQuery,Path,Header,Cookieprovide source-specific validation with aliases, patterns, and constraints- Dependency injection provides reusable, composable pre-endpoint logic
- Dependency chains resolve automatically — auth → user → role → endpoint
- Factory dependencies (
require_role("admin")) create parameterized dependencies yielddependencies handle setup/teardown like context managers- Global and router-level dependencies apply logic to all endpoints at once
UploadFilehandles file uploads with async, memory-efficient streamingdependency_overridesenables clean testing without mocking
Dependencies are FastAPI's secret weapon. Once you start thinking in dependencies, you stop writing monolithic endpoint functions with 200 lines of setup logic. Your endpoints become what they should be — thin handlers that declare what they need and focus on business logic. Everything else is composed from reusable parts ;-)
Thanks for your contribution to the STEMsocial community. Feel free to join us on discord to get to know the rest of us!
Please consider delegating to the @stemsocial account (85% of the curation rewards are returned).
Consider setting @stemsocial as a beneficiary of this post's rewards if you would like to support the community and contribute to its mission of promoting science and education on Hive.