Pydantic is the backbone of FastAPI’s data validation system. Every request body, query parameter, and response model you define in FastAPI is powered by Pydantic under the hood. In this comprehensive tutorial, you will master Pydantic v2 — from basic model definitions and field constraints to custom validators, generic models, and a complete real-world registration system. By the end, you will be able to build bulletproof APIs that validate every piece of incoming data automatically.
fastapi-pydantic and follow along step by step.Pydantic is a data validation and serialization library for Python that uses standard type annotations to define data schemas. When you create a Pydantic model, every field is automatically validated based on its type annotation and any additional constraints you provide. If the data does not match, Pydantic raises a clear, structured error — no manual checking required.
FastAPI was specifically designed around Pydantic because of several key advantages:
| Feature | Benefit |
|---|---|
| Type-first validation | Uses standard Python type hints — no new DSL to learn |
| Automatic JSON Schema | Every model generates OpenAPI-compatible JSON Schema for docs |
| Performance (v2) | Core validation logic rewritten in Rust — up to 50x faster than v1 |
| Serialization | Built-in model_dump() and model_dump_json() for output control |
| Editor support | Full autocomplete and type checking in IDEs like VS Code and PyCharm |
| Ecosystem | Works with SQLAlchemy, MongoDB, settings management, and more |
Pydantic v2 was a major rewrite. Here are the most important changes:
# Pydantic v2 key improvements over v1: # # 1. pydantic-core written in Rust for massive speed gains # 2. model_dump() replaces dict() # 3. model_dump_json() replaces json() # 4. model_validate() replaces parse_obj() # 5. model_validate_json() replaces parse_raw() # 6. model_config replaces class Config # 7. @field_validator replaces @validator # 8. @model_validator replaces @root_validator # 9. ConfigDict for typed configuration # 10. Strict mode for no type coercion
# Pydantic is installed automatically with FastAPI pip install fastapi[standard] # Or install Pydantic separately pip install pydantic # Check your version python -c "import pydantic; print(pydantic.__version__)" # Should show 2.x.x
from pydantic import BaseModel
class User(BaseModel):
name: str
age: int
email: str
# Valid data - works fine
user = User(name="Alice", age=30, email="alice@example.com")
print(user)
# name='Alice' age=30 email='alice@example.com'
print(user.name) # Alice
print(user.age) # 30
# Pydantic coerces compatible types
user2 = User(name="Bob", age="25", email="bob@example.com")
print(user2.age) # 25 (converted string "25" to int)
print(type(user2.age)) # <class 'int'>
# Invalid data - raises ValidationError
try:
bad_user = User(name="Charlie", age="not_a_number", email="charlie@example.com")
except Exception as e:
print(e)
# 1 validation error for User
# age
# Input should be a valid integer, unable to parse string as an integer
Every Pydantic model inherits from BaseModel. Fields are defined using Python type annotations, and you can set defaults, make fields optional, and compose complex structures.
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class Product(BaseModel):
# Required fields - must be provided
name: str
price: float
# Field with a default value
quantity: int = 0
# Optional field - can be None
description: Optional[str] = None
# Field with a default factory
created_at: datetime = datetime.now()
# Only required fields
p1 = Product(name="Laptop", price=999.99)
print(p1)
# name='Laptop' price=999.99 quantity=0 description=None created_at=...
# All fields provided
p2 = Product(
name="Phone",
price=699.99,
quantity=50,
description="Latest smartphone model",
created_at=datetime(2024, 1, 15)
)
print(p2.description) # Latest smartphone model
from pydantic import BaseModel
from typing import Optional, List, Dict
from datetime import datetime, date
from decimal import Decimal
from uuid import UUID, uuid4
from enum import Enum
class StatusEnum(str, Enum):
ACTIVE = "active"
INACTIVE = "inactive"
PENDING = "pending"
class CompleteExample(BaseModel):
# String types
name: str
description: str = "No description"
# Numeric types
age: int
price: float
exact_price: Decimal = Decimal("0.00")
# Boolean
is_active: bool = True
# Date/time
created_at: datetime = datetime.now()
birth_date: Optional[date] = None
# UUID
id: UUID = uuid4()
# Enum
status: StatusEnum = StatusEnum.ACTIVE
# Collections
tags: List[str] = []
metadata: Dict[str, str] = {}
scores: List[float] = []
# Optional fields
nickname: Optional[str] = None
parent_id: Optional[int] = None
# Usage
item = CompleteExample(
name="Test Item",
age=5,
price=29.99,
tags=["sale", "featured"],
metadata={"color": "blue", "size": "large"},
scores=[95.5, 87.3, 92.1]
)
print(item.name) # Test Item
print(item.tags) # ['sale', 'featured']
print(item.status) # StatusEnum.ACTIVE
from pydantic import BaseModel
from typing import Optional
class FieldExamples(BaseModel):
# REQUIRED - no default, must be provided
name: str
# REQUIRED - no default, must be provided (can be None)
middle_name: Optional[str]
# OPTIONAL - has a default of None
nickname: Optional[str] = None
# OPTIONAL - has a default value
country: str = "US"
# This works - all required fields provided
user = FieldExamples(name="Alice", middle_name=None)
print(user)
# name='Alice' middle_name=None nickname=None country='US'
# This fails - middle_name is required even though it is Optional[str]
try:
user = FieldExamples(name="Bob")
except Exception as e:
print(e)
# 1 validation error for FieldExamples
# middle_name
# Field required
Optional[str] without a default value is still required. It means the field accepts str or None, but you must explicitly provide it. To make a field truly optional (not required), give it a default: Optional[str] = None.
from pydantic import BaseModel
class Employee(BaseModel):
first_name: str
last_name: str
department: str
salary: float
@property
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}"
@property
def annual_salary(self) -> float:
return self.salary * 12
def get_summary(self) -> str:
return f"{self.full_name} - {self.department} (${self.salary:,.2f}/month)"
emp = Employee(
first_name="Jane",
last_name="Smith",
department="Engineering",
salary=8500.00
)
print(emp.full_name) # Jane Smith
print(emp.annual_salary) # 102000.0
print(emp.get_summary()) # Jane Smith - Engineering ($8,500.00/month)
Pydantic’s Field() function lets you add constraints to individual fields — minimum/maximum values, string lengths, regex patterns, and more. These constraints are enforced automatically and appear in the generated JSON Schema.
from pydantic import BaseModel, Field
class Product(BaseModel):
name: str = Field(
..., # ... means required
min_length=1,
max_length=100,
description="Product name",
examples=["Laptop Pro 15"]
)
price: float = Field(
...,
gt=0, # greater than 0
le=1_000_000, # less than or equal to 1,000,000
description="Price in USD"
)
quantity: int = Field(
default=0,
ge=0, # greater than or equal to 0
le=10_000,
description="Items in stock"
)
sku: str = Field(
...,
pattern=r"^[A-Z]{2,4}-\d{4,8}$",
description="Stock keeping unit (e.g., PROD-12345)"
)
discount: float = Field(
default=0.0,
ge=0.0,
le=1.0,
description="Discount as a decimal (0.0 to 1.0)"
)
# Valid product
product = Product(
name="Wireless Mouse",
price=29.99,
quantity=150,
sku="WM-12345",
discount=0.1
)
print(product)
# Invalid - triggers multiple validation errors
try:
bad_product = Product(
name="", # too short
price=-5, # not greater than 0
quantity=-1, # not >= 0
sku="invalid", # does not match pattern
discount=1.5 # exceeds max
)
except Exception as e:
print(e)
from pydantic import BaseModel, Field
class NumericConstraints(BaseModel):
# gt = greater than (exclusive minimum)
positive: int = Field(gt=0) # must be > 0
# ge = greater than or equal (inclusive minimum)
non_negative: int = Field(ge=0) # must be >= 0
# lt = less than (exclusive maximum)
below_hundred: int = Field(lt=100) # must be < 100
# le = less than or equal (inclusive maximum)
max_hundred: int = Field(le=100) # must be <= 100
# Combine constraints
percentage: float = Field(ge=0.0, le=100.0) # 0 to 100
# multiple_of
even_number: int = Field(multiple_of=2) # divisible by 2
# Price constraint
price: float = Field(gt=0, le=999999.99)
# Valid
data = NumericConstraints(
positive=1,
non_negative=0,
below_hundred=99,
max_hundred=100,
percentage=85.5,
even_number=42,
price=199.99
)
print(data)
from pydantic import BaseModel, Field
class StringConstraints(BaseModel):
# Length constraints
username: str = Field(min_length=3, max_length=30)
# Exact length (min_length == max_length)
country_code: str = Field(min_length=2, max_length=2)
# Regex pattern for phone
phone: str = Field(pattern=r"^\+?1?\d{9,15}$")
# Email pattern
email: str = Field(pattern=r"^[\w\.-]+@[\w\.-]+\.\w{2,}$")
# URL-safe slug
slug: str = Field(pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
# Password length
password: str = Field(min_length=8, max_length=128)
# Valid data
data = StringConstraints(
username="john_doe",
country_code="US",
phone="+15551234567",
email="john@example.com",
slug="my-blog-post",
password="Str0ng!Pass"
)
print(data.username) # john_doe
from pydantic import BaseModel, Field
class APIResponse(BaseModel):
# alias: the key name in incoming JSON data
item_id: int = Field(alias="itemId")
item_name: str = Field(alias="itemName")
is_available: bool = Field(alias="isAvailable", default=True)
# validation_alias: only for input; serialization_alias: only for output
total_price: float = Field(
validation_alias="total_price",
serialization_alias="totalPrice"
)
# Parse from camelCase JSON
data = {"itemId": 42, "itemName": "Widget", "isAvailable": True, "total_price": 29.99}
response = APIResponse(**data)
print(response.item_id) # 42
# Serialize with Python names (default)
print(response.model_dump())
# {'item_id': 42, 'item_name': 'Widget', 'is_available': True, 'total_price': 29.99}
# Serialize with aliases
print(response.model_dump(by_alias=True))
# {'itemId': 42, 'itemName': 'Widget', 'isAvailable': True, 'totalPrice': 29.99}
from pydantic import BaseModel, EmailStr, HttpUrl, IPvAnyAddress
from pydantic import PositiveInt, PositiveFloat, NonNegativeInt
from pydantic import constr, conint, confloat
class BuiltInTypes(BaseModel):
# Email validation (requires: pip install pydantic[email])
email: EmailStr
# URL validation
website: HttpUrl
# IP address
server_ip: IPvAnyAddress
# Constrained positive numbers
user_id: PositiveInt # must be > 0
rating: PositiveFloat # must be > 0.0
retry_count: NonNegativeInt # must be >= 0
# Constrained types (alternative to Field())
username: constr(min_length=3, max_length=50)
age: conint(ge=0, le=150)
score: confloat(ge=0.0, le=100.0)
data = BuiltInTypes(
email="user@example.com",
website="https://example.com",
server_ip="192.168.1.1",
user_id=42,
rating=4.5,
retry_count=0,
username="john_doe",
age=30,
score=95.5
)
print(data.email) # user@example.com
print(data.website) # https://example.com/
Pydantic leverages Python’s type annotation system extensively. Understanding how different type hints translate to validation rules is essential for building robust models.
from pydantic import BaseModel
class BasicTypes(BaseModel):
name: str # accepts strings
age: int # accepts integers, coerces numeric strings
height: float # accepts floats, coerces ints
is_active: bool # accepts booleans, coerces "true"/"false"
data: bytes # accepts bytes and strings
# Type coercion examples
model = BasicTypes(
name="Alice",
age="30", # string "30" coerced to int 30
height=5, # int 5 coerced to float 5.0
is_active="true", # string "true" coerced to True
data="hello" # string coerced to b"hello"
)
print(f"age: {model.age} (type: {type(model.age).__name__})")
# age: 30 (type: int)
print(f"height: {model.height} (type: {type(model.height).__name__})")
# height: 5.0 (type: float)
print(f"is_active: {model.is_active} (type: {type(model.is_active).__name__})")
# is_active: True (type: bool)
from pydantic import BaseModel, Field
from typing import List, Dict, Set, Tuple, FrozenSet
class CollectionTypes(BaseModel):
tags: List[str] = []
scores: List[int] = Field(default=[], min_length=1, max_length=100)
metadata: Dict[str, str] = {}
unique_tags: Set[str] = set()
coordinates: Tuple[float, float] = (0.0, 0.0)
values: Tuple[int, ...] = ()
permissions: FrozenSet[str] = frozenset()
data = CollectionTypes(
tags=["python", "fastapi", "tutorial"],
scores=[95, 87, 92, 88],
metadata={"author": "Alice", "version": "2.0"},
unique_tags=["python", "python", "fastapi"], # duplicates removed
coordinates=(40.7128, -74.0060),
values=(1, 2, 3, 4, 5),
permissions=["read", "write", "read"] # duplicates removed
)
print(data.unique_tags) # {'python', 'fastapi'}
print(data.permissions) # frozenset({'read', 'write'})
from pydantic import BaseModel
from typing import Optional, Union, Literal
class AdvancedTypes(BaseModel):
nickname: Optional[str] = None
identifier: Union[int, str]
role: Literal["admin", "user", "moderator"]
priority: Literal[1, 2, 3, "high", "medium", "low"]
# Valid
data = AdvancedTypes(identifier=42, role="admin", priority="high")
print(data)
# Union tries types in order
data2 = AdvancedTypes(identifier="user-abc-123", role="user", priority=2)
print(data2.identifier) # user-abc-123
# Invalid literal
try:
AdvancedTypes(identifier=1, role="superadmin", priority=5)
except Exception as e:
print(e)
from pydantic import BaseModel, Field
from typing import Annotated
from pydantic.functional_validators import AfterValidator
# Create reusable validated types with Annotated
PositiveName = Annotated[str, Field(min_length=1, max_length=100)]
Percentage = Annotated[float, Field(ge=0.0, le=100.0)]
Port = Annotated[int, Field(ge=1, le=65535)]
def normalize_email(v: str) -> str:
return v.lower().strip()
NormalizedEmail = Annotated[str, AfterValidator(normalize_email)]
class ServerConfig(BaseModel):
name: PositiveName
host: str
port: Port
cpu_usage: Percentage
admin_email: NormalizedEmail
config = ServerConfig(
name="Production Server",
host="api.example.com",
port=8080,
cpu_usage=67.5,
admin_email=" ADMIN@Example.COM "
)
print(config.admin_email) # admin@example.com (normalized!)
print(config.port) # 8080
Annotated types are the recommended way in Pydantic v2 to create reusable field definitions. They keep your models clean and ensure consistent validation across your codebase.Real-world data is rarely flat. Pydantic excels at validating deeply nested structures by composing models together. Each nested model is fully validated independently.
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
class Address(BaseModel):
street: str = Field(min_length=1, max_length=200)
city: str = Field(min_length=1, max_length=100)
state: str = Field(min_length=2, max_length=2)
zip_code: str = Field(pattern=r"^\d{5}(-\d{4})?$")
country: str = "US"
class ContactInfo(BaseModel):
email: str = Field(pattern=r"^[\w\.-]+@[\w\.-]+\.\w{2,}$")
phone: Optional[str] = Field(default=None, pattern=r"^\+?1?\d{10,15}$")
address: Address
class User(BaseModel):
id: int
name: str = Field(min_length=1, max_length=100)
contact: ContactInfo
created_at: datetime = Field(default_factory=datetime.now)
# Create with nested data
user = User(
id=1,
name="Alice Johnson",
contact={
"email": "alice@example.com",
"phone": "+15551234567",
"address": {
"street": "123 Main St",
"city": "Springfield",
"state": "IL",
"zip_code": "62701"
}
}
)
# Access nested fields
print(user.contact.email) # alice@example.com
print(user.contact.address.city) # Springfield
print(user.contact.address.zip_code) # 62701
# Nested validation works automatically
try:
bad_user = User(
id=2,
name="Bob",
contact={
"email": "bob@example.com",
"address": {
"street": "456 Oak Ave",
"city": "Chicago",
"state": "Illinois", # too long - must be 2 chars
"zip_code": "abc" # does not match pattern
}
}
)
except Exception as e:
print(e)
# Shows errors at contact -> address -> state and zip_code
from pydantic import BaseModel, Field
from typing import List, Optional
from enum import Enum
class OrderStatus(str, Enum):
PENDING = "pending"
PROCESSING = "processing"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
class OrderItem(BaseModel):
product_name: str = Field(min_length=1)
quantity: int = Field(gt=0, le=1000)
unit_price: float = Field(gt=0)
@property
def total(self) -> float:
return round(self.quantity * self.unit_price, 2)
class ShippingAddress(BaseModel):
name: str
street: str
city: str
state: str
zip_code: str
class Order(BaseModel):
order_id: str = Field(pattern=r"^ORD-\d{6,10}$")
customer_id: int = Field(gt=0)
items: List[OrderItem] = Field(min_length=1, max_length=50)
shipping: ShippingAddress
status: OrderStatus = OrderStatus.PENDING
notes: Optional[str] = None
@property
def order_total(self) -> float:
return round(sum(item.total for item in self.items), 2)
@property
def item_count(self) -> int:
return sum(item.quantity for item in self.items)
# Create a complete order
order = Order(
order_id="ORD-123456",
customer_id=42,
items=[
{"product_name": "Laptop", "quantity": 1, "unit_price": 999.99},
{"product_name": "Mouse", "quantity": 2, "unit_price": 29.99},
{"product_name": "Keyboard", "quantity": 1, "unit_price": 79.99},
],
shipping={
"name": "Alice Johnson",
"street": "123 Main St",
"city": "Springfield",
"state": "IL",
"zip_code": "62701"
},
notes="Please leave at front door"
)
print(f"Order: {order.order_id}")
print(f"Items: {len(order.items)}")
print(f"Total items: {order.item_count}")
print(f"Order total: ${order.order_total}")
# Order total: $1139.96
from __future__ import annotations
from pydantic import BaseModel, Field
from typing import Optional, List
class Comment(BaseModel):
id: int
author: str
text: str = Field(min_length=1, max_length=5000)
replies: List[Comment] = [] # self-referential!
@property
def reply_count(self) -> int:
count = len(self.replies)
for reply in self.replies:
count += reply.reply_count
return count
class Category(BaseModel):
name: str
slug: str
children: List[Category] = []
def find(self, slug: str) -> Optional[Category]:
if self.slug == slug:
return self
for child in self.children:
result = child.find(slug)
if result:
return result
return None
# Build a comment tree
thread = Comment(
id=1,
author="Alice",
text="Great tutorial on Pydantic!",
replies=[
{
"id": 2,
"author": "Bob",
"text": "Thanks Alice! I learned a lot.",
"replies": [
{"id": 3, "author": "Alice", "text": "Glad to hear that!"}
]
},
{"id": 4, "author": "Charlie", "text": "Could you explain validators more?"}
]
)
print(f"Total replies: {thread.reply_count}") # Total replies: 3
# Build a category tree
categories = Category(
name="Programming",
slug="programming",
children=[
{
"name": "Python",
"slug": "python",
"children": [
{"name": "FastAPI", "slug": "fastapi"},
{"name": "Django", "slug": "django"},
]
},
{
"name": "JavaScript",
"slug": "javascript",
"children": [
{"name": "React", "slug": "react"},
{"name": "Node.js", "slug": "nodejs"},
]
}
]
)
found = categories.find("fastapi")
if found:
print(f"Found: {found.name}") # Found: FastAPI
While built-in constraints handle most cases, custom validators let you implement any validation logic you need. Pydantic v2 provides @field_validator for single fields and @model_validator for cross-field validation.
from pydantic import BaseModel, Field, field_validator
class UserRegistration(BaseModel):
username: str = Field(min_length=3, max_length=30)
email: str
password: str = Field(min_length=8)
age: int = Field(ge=13)
@field_validator("username")
@classmethod
def username_must_be_alphanumeric(cls, v: str) -> str:
if not v.replace("_", "").isalnum():
raise ValueError("Username must contain only letters, numbers, and underscores")
if v[0].isdigit():
raise ValueError("Username cannot start with a number")
return v.lower() # normalize to lowercase
@field_validator("email")
@classmethod
def validate_email(cls, v: str) -> str:
if "@" not in v:
raise ValueError("Invalid email address")
local, domain = v.rsplit("@", 1)
if not local or not domain or "." not in domain:
raise ValueError("Invalid email address")
return v.lower().strip()
@field_validator("password")
@classmethod
def password_strength(cls, v: str) -> str:
if not any(c.isupper() for c in v):
raise ValueError("Password must contain at least one uppercase letter")
if not any(c.islower() for c in v):
raise ValueError("Password must contain at least one lowercase letter")
if not any(c.isdigit() for c in v):
raise ValueError("Password must contain at least one digit")
if not any(c in "!@#$%^&*()_+-=[]{}|;:,./?" for c in v):
raise ValueError("Password must contain at least one special character")
return v
# Valid registration
user = UserRegistration(
username="Alice_Dev",
email="ALICE@Example.COM",
password="Str0ng!Pass",
age=25
)
print(user.username) # alice_dev (normalized)
print(user.email) # alice@example.com (normalized)
# Invalid registration
try:
UserRegistration(
username="123invalid",
email="not-email",
password="weakpass",
age=10
)
except Exception as e:
print(e)
from pydantic import BaseModel, field_validator
class DataProcessor(BaseModel):
value: int
tags: list[str]
name: str
# BEFORE validator - runs before Pydantic's type validation
@field_validator("value", mode="before")
@classmethod
def parse_value(cls, v):
if isinstance(v, str):
cleaned = v.strip().replace("$", "").replace("%", "").replace(",", "")
try:
return int(float(cleaned))
except ValueError:
raise ValueError(f"Cannot parse '{v}' as a number")
return v
# AFTER validator (default) - runs after type validation
@field_validator("tags")
@classmethod
def normalize_tags(cls, v: list[str]) -> list[str]:
seen = set()
result = []
for tag in v:
normalized = tag.lower().strip()
if normalized and normalized not in seen:
seen.add(normalized)
result.append(normalized)
return result
# BEFORE validator for type coercion
@field_validator("name", mode="before")
@classmethod
def coerce_name(cls, v):
if not isinstance(v, str):
return str(v)
return v
# Before validator handles type conversion
data = DataProcessor(
value="$1,234",
tags=["Python", "PYTHON", " fastapi ", "FastAPI", "tutorial"],
name=42
)
print(data.value) # 1234
print(data.tags) # ['python', 'fastapi', 'tutorial']
print(data.name) # 42 (converted to string)
from pydantic import BaseModel, Field, field_validator
class PriceRange(BaseModel):
min_price: float = Field(ge=0)
max_price: float = Field(ge=0)
currency: str = Field(min_length=3, max_length=3)
# Apply one validator to multiple fields
@field_validator("min_price", "max_price")
@classmethod
def round_price(cls, v: float) -> float:
return round(v, 2)
@field_validator("currency")
@classmethod
def uppercase_currency(cls, v: str) -> str:
return v.upper()
data = PriceRange(min_price=10.555, max_price=99.999, currency="usd")
print(data.min_price) # 10.56
print(data.max_price) # 100.0
print(data.currency) # USD
from pydantic import BaseModel, Field, model_validator
from datetime import date
from typing import Optional
class DateRange(BaseModel):
start_date: date
end_date: date
@model_validator(mode="after")
def validate_date_range(self):
if self.start_date >= self.end_date:
raise ValueError("start_date must be before end_date")
return self
class PasswordChange(BaseModel):
current_password: str
new_password: str = Field(min_length=8)
confirm_password: str
@model_validator(mode="after")
def validate_passwords(self):
if self.new_password != self.confirm_password:
raise ValueError("new_password and confirm_password must match")
if self.new_password == self.current_password:
raise ValueError("New password must be different from current password")
return self
class DiscountRule(BaseModel):
discount_type: str # "percentage" or "fixed"
discount_value: float
min_order: float = 0.0
max_discount: Optional[float] = None
@model_validator(mode="after")
def validate_discount(self):
if self.discount_type == "percentage":
if not (0 < self.discount_value <= 100):
raise ValueError("Percentage discount must be between 0 and 100")
elif self.discount_type == "fixed":
if self.discount_value <= 0:
raise ValueError("Fixed discount must be positive")
else:
raise ValueError("discount_type must be 'percentage' or 'fixed'")
return self
# Valid date range
dr = DateRange(start_date="2024-01-01", end_date="2024-12-31")
print(dr)
# Invalid - passwords don't match
try:
PasswordChange(
current_password="old",
new_password="NewStr0ng!Pass",
confirm_password="DifferentPass"
)
except Exception as e:
print(e)
from pydantic import BaseModel, model_validator
from typing import Any
class FlexibleInput(BaseModel):
name: str
value: float
@model_validator(mode="before")
@classmethod
def normalize_input(cls, data: Any) -> dict:
# Handle string input like "name:value"
if isinstance(data, str):
parts = data.split(":")
if len(parts) != 2:
raise ValueError("String input must be 'name:value' format")
return {"name": parts[0].strip(), "value": float(parts[1].strip())}
# Handle tuple/list input
if isinstance(data, (list, tuple)):
if len(data) != 2:
raise ValueError("Sequence must have exactly 2 elements")
return {"name": str(data[0]), "value": float(data[1])}
# Handle dict with alternative key names
if isinstance(data, dict):
if "label" in data and "name" not in data:
data["name"] = data.pop("label")
if "amount" in data and "value" not in data:
data["value"] = data.pop("amount")
return data
# All of these work:
m1 = FlexibleInput(name="temperature", value=98.6)
m2 = FlexibleInput.model_validate("humidity:65.0")
m3 = FlexibleInput.model_validate(["pressure", 1013.25])
m4 = FlexibleInput.model_validate({"label": "wind", "amount": 15.5})
print(m1) # name='temperature' value=98.6
print(m2) # name='humidity' value=65.0
print(m3) # name='pressure' value=1013.25
print(m4) # name='wind' value=15.5
from pydantic import BaseModel, Field, ValidationError, field_validator
class StrictUser(BaseModel):
name: str = Field(min_length=2, max_length=50)
age: int = Field(ge=0, le=150)
email: str
@field_validator("email")
@classmethod
def validate_email(cls, v):
if "@" not in v:
raise ValueError("Must contain @")
return v
# Trigger multiple validation errors
try:
StrictUser(name="A", age=-5, email="invalid")
except ValidationError as e:
print(f"Error count: {e.error_count()}")
for error in e.errors():
field = ' -> '.join(str(loc) for loc in error['loc'])
print(f"\nField: {field}")
print(f" Type: {error['type']}")
print(f" Message: {error['msg']}")
print(f" Input: {error['input']}")
# JSON format for API responses
import json
print(json.dumps(e.errors(), indent=2, default=str))
Computed fields are derived from other field values and are included automatically in serialization and JSON schema output. Pydantic v2 introduced the @computed_field decorator for this purpose.
from pydantic import BaseModel, Field, computed_field
class Product(BaseModel):
name: str
base_price: float = Field(gt=0)
tax_rate: float = Field(ge=0, le=1, default=0.08)
discount: float = Field(ge=0, le=1, default=0)
@computed_field
@property
def discount_amount(self) -> float:
return round(self.base_price * self.discount, 2)
@computed_field
@property
def subtotal(self) -> float:
return round(self.base_price - self.discount_amount, 2)
@computed_field
@property
def tax_amount(self) -> float:
return round(self.subtotal * self.tax_rate, 2)
@computed_field
@property
def total_price(self) -> float:
return round(self.subtotal + self.tax_amount, 2)
product = Product(name="Laptop", base_price=999.99, discount=0.15)
print(f"Base price: ${product.base_price}")
print(f"Discount: -${product.discount_amount}")
print(f"Subtotal: ${product.subtotal}")
print(f"Tax: +${product.tax_amount}")
print(f"Total: ${product.total_price}")
# Computed fields are included in model_dump()!
data = product.model_dump()
print(data)
# Includes: discount_amount, subtotal, tax_amount, total_price
from pydantic import BaseModel, Field, computed_field
from datetime import date
from typing import List
class Student(BaseModel):
first_name: str
last_name: str
birth_date: date
grades: List[float] = Field(default=[])
@computed_field
@property
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}"
@computed_field
@property
def age(self) -> int:
today = date.today()
born = self.birth_date
return today.year - born.year - (
(today.month, today.day) < (born.month, born.day)
)
@computed_field
@property
def gpa(self) -> float:
if not self.grades:
return 0.0
return round(sum(self.grades) / len(self.grades), 2)
@computed_field
@property
def letter_grade(self) -> str:
gpa = self.gpa
if gpa >= 90: return "A"
elif gpa >= 80: return "B"
elif gpa >= 70: return "C"
elif gpa >= 60: return "D"
return "F"
@computed_field
@property
def honor_roll(self) -> bool:
return self.gpa >= 85.0 and len(self.grades) >= 3
student = Student(
first_name="Alice",
last_name="Johnson",
birth_date="2000-05-15",
grades=[92, 88, 95, 91, 87]
)
print(student.model_dump())
# Includes: full_name, age, gpa, letter_grade, honor_roll
Pydantic v2 uses model_config (a ConfigDict) to control model behavior — how it handles extra fields, whether it strips whitespace, whether it can read from ORM objects, and more.
from pydantic import BaseModel, ConfigDict
class StrictModel(BaseModel):
model_config = ConfigDict(
extra="forbid", # Reject extra fields
str_strip_whitespace=True, # Strip whitespace from strings
str_to_lower=True, # Convert strings to lowercase
frozen=False, # Allow mutation
use_enum_values=True, # Use enum values in serialization
validate_default=True, # Validate default values
)
name: str
email: str
role: str = "user"
# str_strip_whitespace and str_to_lower in action
user = StrictModel(
name=" ALICE JOHNSON ",
email=" Alice@Example.COM "
)
print(user.name) # alice johnson (stripped and lowered)
print(user.email) # alice@example.com
# extra="forbid" rejects unknown fields
try:
StrictModel(name="Bob", email="bob@test.com", unknown_field="value")
except Exception as e:
print(e)
# Extra inputs are not permitted
from pydantic import BaseModel, ConfigDict
class ForbidExtra(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
class IgnoreExtra(BaseModel):
model_config = ConfigDict(extra="ignore")
name: str
class AllowExtra(BaseModel):
model_config = ConfigDict(extra="allow")
name: str
data = {"name": "Alice", "age": 30, "role": "admin"}
# forbid - raises error
try:
ForbidExtra(**data)
except Exception as e:
print(f"Forbid: {e}")
# ignore - silently drops extra fields
m2 = IgnoreExtra(**data)
print(f"Ignore: {m2.model_dump()}")
# {'name': 'Alice'}
# allow - keeps extra fields
m3 = AllowExtra(**data)
print(f"Allow: {m3.model_dump()}")
# {'name': 'Alice', 'age': 30, 'role': 'admin'}
from pydantic import BaseModel, ConfigDict
# Simulating an ORM model (like SQLAlchemy)
class UserORM:
def __init__(self, id, name, email, is_active):
self.id = id
self.name = name
self.email = email
self.is_active = is_active
class UserSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
email: str
is_active: bool
# Create from ORM object
orm_user = UserORM(id=1, name="Alice", email="alice@example.com", is_active=True)
# model_validate reads attributes from the object
user_schema = UserSchema.model_validate(orm_user)
print(user_schema)
# id=1 name='Alice' email='alice@example.com' is_active=True
print(user_schema.model_dump())
# {'id': 1, 'name': 'Alice', 'email': 'alice@example.com', 'is_active': True}
# This is essential for FastAPI + SQLAlchemy integration
from pydantic import BaseModel, Field, ConfigDict
import json
class CreateUserRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"username": "alice_dev",
"email": "alice@example.com",
"full_name": "Alice Johnson",
"age": 28
}
]
}
)
username: str = Field(
min_length=3, max_length=30,
description="Unique username for the account"
)
email: str = Field(description="Valid email address")
full_name: str = Field(min_length=1, max_length=100)
age: int = Field(ge=13, le=150, description="Must be 13 or older")
# View the generated JSON Schema
schema = CreateUserRequest.model_json_schema()
print(json.dumps(schema, indent=2))
# This schema is automatically used by FastAPI for Swagger UI
from pydantic import BaseModel, ConfigDict
class ImmutableConfig(BaseModel):
model_config = ConfigDict(frozen=True)
host: str
port: int
debug: bool = False
config = ImmutableConfig(host="localhost", port=8000, debug=True)
print(config.host) # localhost
# Cannot modify frozen model
try:
config.host = "production.example.com"
except Exception as e:
print(e) # Instance is frozen
# Create a new instance with changes using model_copy
new_config = config.model_copy(
update={"host": "production.example.com", "debug": False}
)
print(new_config.host) # production.example.com
print(config.host) # localhost (original unchanged)
Serialization is the process of converting your Pydantic model to a dictionary, JSON, or other format for output. Pydantic v2 provides powerful serialization controls through model_dump() and model_dump_json().
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
class UserProfile(BaseModel):
id: int
username: str
email: str
full_name: Optional[str] = None
bio: Optional[str] = None
avatar_url: Optional[str] = None
is_active: bool = True
role: str = "user"
login_count: int = 0
created_at: datetime = Field(default_factory=datetime.now)
tags: List[str] = []
user = UserProfile(
id=1,
username="alice",
email="alice@example.com",
full_name="Alice Johnson",
bio="Software developer",
tags=["python", "fastapi"]
)
# Exclude specific fields
public_data = user.model_dump(exclude={"email", "login_count", "is_active"})
print(public_data)
# Include only specific fields
summary = user.model_dump(include={"id", "username", "full_name"})
print(summary)
# {'id': 1, 'username': 'alice', 'full_name': 'Alice Johnson'}
# Exclude None values
clean_data = user.model_dump(exclude_none=True)
print(clean_data) # avatar_url won't appear
# Exclude unset values (only fields explicitly provided)
explicit_data = user.model_dump(exclude_unset=True)
print(explicit_data)
# Only id, username, email, full_name, bio, tags appear
# Exclude defaults
non_default = user.model_dump(exclude_defaults=True)
print(non_default)
from pydantic import BaseModel
from typing import Optional
class Address(BaseModel):
street: str
city: str
state: str
zip_code: str
country: str = "US"
class ContactInfo(BaseModel):
email: str
phone: Optional[str] = None
address: Address
class UserFull(BaseModel):
id: int
name: str
password_hash: str
contact: ContactInfo
internal_notes: Optional[str] = None
user = UserFull(
id=1,
name="Alice",
password_hash="$2b$12$abc123...",
contact={
"email": "alice@example.com",
"phone": "+15551234567",
"address": {
"street": "123 Main St",
"city": "Springfield",
"state": "IL",
"zip_code": "62701"
}
},
internal_notes="VIP customer"
)
# Exclude nested fields using dict notation
public_data = user.model_dump(
exclude={
"password_hash": True,
"internal_notes": True,
"contact": {
"phone": True,
"address": {"zip_code", "country"}
}
}
)
print(public_data)
# password_hash, internal_notes, phone, zip_code, country all excluded
# Include only specific nested fields
minimal = user.model_dump(
include={
"id": True,
"name": True,
"contact": {"email": True}
}
)
print(minimal)
# {'id': 1, 'name': 'Alice', 'contact': {'email': 'alice@example.com'}}
from pydantic import BaseModel, Field
from datetime import datetime, date
from typing import List
from decimal import Decimal
from uuid import UUID, uuid4
class Invoice(BaseModel):
id: UUID = Field(default_factory=uuid4)
customer: str
amount: Decimal
tax: Decimal
issue_date: date
due_date: date
items: List[str]
created_at: datetime = Field(default_factory=datetime.now)
invoice = Invoice(
customer="Acme Corp",
amount=Decimal("1500.00"),
tax=Decimal("120.00"),
issue_date="2024-03-15",
due_date="2024-04-15",
items=["Consulting", "Development", "Testing"]
)
# model_dump_json() handles special types automatically
json_str = invoice.model_dump_json(indent=2)
print(json_str)
# model_dump() vs model_dump_json() with special types
dict_data = invoice.model_dump()
print(type(dict_data["id"])) # UUID object
print(type(dict_data["amount"])) # Decimal object
# model_dump with mode="json" converts to JSON-compatible types
json_dict = invoice.model_dump(mode="json")
print(type(json_dict["id"])) # str
print(type(json_dict["amount"])) # str
from pydantic import BaseModel, field_serializer
from datetime import datetime
from decimal import Decimal
class Transaction(BaseModel):
id: int
amount: Decimal
currency: str = "USD"
timestamp: datetime
description: str
@field_serializer("amount")
def serialize_amount(self, value: Decimal) -> str:
return f"{value:.2f}"
@field_serializer("timestamp")
def serialize_timestamp(self, value: datetime) -> str:
return value.strftime("%Y-%m-%dT%H:%M:%SZ")
@field_serializer("description")
def serialize_description(self, value: str) -> str:
if len(value) > 100:
return value[:97] + "..."
return value
tx = Transaction(
id=1,
amount=Decimal("1234.5"),
timestamp=datetime(2024, 3, 15, 14, 30, 0),
description="Payment for consulting services"
)
print(tx.model_dump())
# amount shows as '1234.50', timestamp as '2024-03-15T14:30:00Z'
from pydantic import BaseModel, Field, ConfigDict
class APIItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
item_id: int = Field(alias="itemId")
item_name: str = Field(alias="itemName")
unit_price: float = Field(alias="unitPrice")
is_available: bool = Field(alias="isAvailable", default=True)
# Parse from camelCase (API input)
item = APIItem(**{"itemId": 1, "itemName": "Widget", "unitPrice": 9.99})
# Or parse using Python names (with populate_by_name=True)
item2 = APIItem(item_id=2, item_name="Gadget", unit_price=19.99)
# Serialize with Python names (default)
print(item.model_dump())
# {'item_id': 1, 'item_name': 'Widget', ...}
# Serialize with camelCase aliases
print(item.model_dump(by_alias=True))
# {'itemId': 1, 'itemName': 'Widget', ...}
Now that you understand Pydantic models, let us see how FastAPI integrates them for automatic request validation. FastAPI uses Pydantic to validate request bodies, query parameters, path parameters, and headers.
from fastapi import FastAPI, Body
from pydantic import BaseModel, Field, field_validator
from typing import Optional, List
from datetime import datetime
app = FastAPI()
class CreateProductRequest(BaseModel):
name: str = Field(min_length=1, max_length=200, description="Product name")
description: Optional[str] = Field(default=None, max_length=5000)
price: float = Field(gt=0, le=1_000_000, description="Price in USD")
quantity: int = Field(ge=0, le=100_000, default=0)
tags: List[str] = Field(default=[], max_length=20)
sku: str = Field(pattern=r"^[A-Z]{2,4}-\d{4,8}$")
@field_validator("tags")
@classmethod
def normalize_tags(cls, v):
return [tag.lower().strip() for tag in v if tag.strip()]
class ProductResponse(BaseModel):
id: int
name: str
description: Optional[str]
price: float
quantity: int
tags: List[str]
sku: str
created_at: datetime
@app.post("/products", response_model=ProductResponse, status_code=201)
async def create_product(product: CreateProductRequest):
# FastAPI automatically:
# 1. Reads the JSON request body
# 2. Validates it against CreateProductRequest
# 3. Returns 422 with details if validation fails
# 4. Provides the validated model to your function
return ProductResponse(
id=1,
**product.model_dump(),
created_at=datetime.now()
)
# Test with curl:
curl -X POST http://localhost:8000/products \
-H "Content-Type: application/json" \
-d '{
"name": "Wireless Mouse",
"price": 29.99,
"quantity": 100,
"tags": ["electronics", "accessories"],
"sku": "WM-12345"
}'
from fastapi import FastAPI, Query
from typing import Optional, List
from enum import Enum
app = FastAPI()
class SortOrder(str, Enum):
ASC = "asc"
DESC = "desc"
class SortField(str, Enum):
NAME = "name"
PRICE = "price"
CREATED = "created_at"
@app.get("/products")
async def list_products(
search: Optional[str] = Query(default=None, min_length=1, max_length=100),
page: int = Query(default=1, ge=1, le=10000),
page_size: int = Query(default=20, ge=1, le=100),
min_price: Optional[float] = Query(default=None, ge=0),
max_price: Optional[float] = Query(default=None, ge=0),
sort_by: SortField = Query(default=SortField.CREATED),
sort_order: SortOrder = Query(default=SortOrder.DESC),
tags: Optional[List[str]] = Query(default=None, max_length=10),
in_stock: Optional[bool] = Query(default=None),
):
# All query params are validated automatically
# Example: /products?search=mouse&page=1&min_price=10&sort_by=price
return {
"search": search,
"page": page,
"page_size": page_size,
"filters": {
"min_price": min_price,
"max_price": max_price,
"sort_by": sort_by,
"sort_order": sort_order,
"tags": tags,
"in_stock": in_stock
}
}
from fastapi import FastAPI, Path
from enum import Enum
from uuid import UUID
app = FastAPI()
class ResourceType(str, Enum):
USERS = "users"
PRODUCTS = "products"
ORDERS = "orders"
@app.get("/items/{item_id}")
async def get_item(
item_id: int = Path(..., gt=0, le=1_000_000, description="The item ID")
):
return {"item_id": item_id}
@app.get("/users/{user_id}/orders/{order_id}")
async def get_user_order(
user_id: int = Path(..., gt=0),
order_id: int = Path(..., gt=0),
):
return {"user_id": user_id, "order_id": order_id}
# UUID path parameter
@app.get("/resources/{resource_id}")
async def get_resource(resource_id: UUID):
return {"resource_id": str(resource_id)}
# Enum path parameter
@app.get("/api/{resource_type}/{resource_id}")
async def get_any_resource(
resource_type: ResourceType,
resource_id: int = Path(..., gt=0)
):
return {"resource_type": resource_type.value, "resource_id": resource_id}
from fastapi import FastAPI, Header, Cookie
from typing import Optional, List
app = FastAPI()
@app.get("/protected")
async def protected_route(
authorization: str = Header(..., alias="Authorization"),
accept_language: str = Header(default="en", alias="Accept-Language"),
x_request_id: Optional[str] = Header(default=None, alias="X-Request-ID"),
):
return {
"auth": authorization[:20] + "...",
"language": accept_language,
"request_id": x_request_id,
}
@app.get("/preferences")
async def get_preferences(
session_id: Optional[str] = Cookie(default=None),
theme: str = Cookie(default="light"),
language: str = Cookie(default="en"),
):
return {"session_id": session_id, "theme": theme, "language": language}
from fastapi import FastAPI, Body
from pydantic import BaseModel, Field
app = FastAPI()
class Item(BaseModel):
name: str = Field(min_length=1)
price: float = Field(gt=0)
description: str = ""
class UserInfo(BaseModel):
username: str
email: str
@app.post("/purchase")
async def make_purchase(
item: Item,
user: UserInfo,
quantity: int = Body(ge=1, le=100),
notes: str = Body(default=""),
):
# Expected JSON body:
# {
# "item": {"name": "Widget", "price": 9.99},
# "user": {"username": "alice", "email": "alice@example.com"},
# "quantity": 3,
# "notes": "Gift wrap please"
# }
total = item.price * quantity
return {
"item": item.model_dump(),
"user": user.model_dump(),
"quantity": quantity,
"total": total,
"notes": notes
}
# Force single model to use key in body with embed=True
@app.post("/items")
async def create_item(item: Item = Body(embed=True)):
# With embed=True, expects: {"item": {"name": "Widget", "price": 9.99}}
return item
Response models control what data your API returns. They ensure sensitive fields are never leaked, provide consistent output structures, and generate accurate API documentation.
from fastapi import FastAPI
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
app = FastAPI()
# Input model - what the client sends
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=30)
email: str
password: str = Field(min_length=8)
full_name: Optional[str] = None
# Output model - what the client receives (no password!)
class UserResponse(BaseModel):
id: int
username: str
email: str
full_name: Optional[str] = None
is_active: bool
created_at: datetime
fake_db = {}
next_id = 1
@app.post("/users", response_model=UserResponse, status_code=201)
async def create_user(user: UserCreate):
# response_model=UserResponse ensures:
# 1. Only fields in UserResponse are returned
# 2. password is NEVER included in the response
# 3. Swagger docs show the correct response schema
global next_id
db_user = {
"id": next_id,
"username": user.username,
"email": user.email,
"full_name": user.full_name,
"password_hash": f"hashed_{user.password}",
"is_active": True,
"created_at": datetime.now(),
"internal_notes": "New user"
}
fake_db[next_id] = db_user
next_id += 1
# Even though db_user has password_hash and internal_notes,
# response_model filters them out!
return db_user
@app.get("/users", response_model=List[UserResponse])
async def list_users():
return list(fake_db.values())
from fastapi import FastAPI
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
app = FastAPI()
# Shared base
class ProductBase(BaseModel):
name: str = Field(min_length=1, max_length=200)
description: Optional[str] = None
price: float = Field(gt=0)
category: str
# Create input
class ProductCreate(ProductBase):
sku: str = Field(pattern=r"^[A-Z]{2,4}-\d{4,8}$")
initial_stock: int = Field(ge=0, default=0)
# Update input - all fields optional
class ProductUpdate(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=200)
description: Optional[str] = None
price: Optional[float] = Field(default=None, gt=0)
category: Optional[str] = None
# Full response
class ProductResponse(ProductBase):
id: int
sku: str
stock: int
is_active: bool
created_at: datetime
updated_at: datetime
# Summary response (for list views)
class ProductSummary(BaseModel):
id: int
name: str
price: float
category: str
in_stock: bool
@app.post("/products", response_model=ProductResponse, status_code=201)
async def create_product(product: ProductCreate):
return {
"id": 1,
**product.model_dump(),
"stock": product.initial_stock,
"is_active": True,
"created_at": datetime.now(),
"updated_at": datetime.now()
}
@app.patch("/products/{product_id}", response_model=ProductResponse)
async def update_product(product_id: int, product: ProductUpdate):
updates = product.model_dump(exclude_unset=True)
# In real app: apply updates to database
return {
"id": product_id,
"name": updates.get("name", "Existing Product"),
"description": updates.get("description"),
"price": updates.get("price", 29.99),
"category": updates.get("category", "general"),
"sku": "PROD-1234",
"stock": 50,
"is_active": True,
"created_at": datetime.now(),
"updated_at": datetime.now()
}
@app.get("/products", response_model=list[ProductSummary])
async def list_products():
return [
{"id": 1, "name": "Wireless Mouse", "price": 29.99,
"category": "electronics", "in_stock": True},
{"id": 2, "name": "Keyboard", "price": 59.99,
"category": "electronics", "in_stock": False}
]
from fastapi import FastAPI
from pydantic import BaseModel
from typing import Optional
app = FastAPI()
class UserOut(BaseModel):
id: int
name: str
email: str
bio: Optional[str] = None
avatar: Optional[str] = None
settings: dict = {}
# Exclude None values from response
@app.get("/users/{user_id}", response_model=UserOut,
response_model_exclude_none=True)
async def get_user(user_id: int):
return {
"id": user_id, "name": "Alice", "email": "alice@example.com",
"bio": None, "avatar": None, "settings": {}
}
# Response: {"id": 1, "name": "Alice", "email": "alice@example.com", "settings": {}}
# Exclude specific fields
@app.get("/users/{user_id}/public", response_model=UserOut,
response_model_exclude={"email", "settings"})
async def get_public_user(user_id: int):
return {
"id": user_id, "name": "Alice", "email": "alice@example.com",
"bio": "Developer", "settings": {"theme": "dark"}
}
# Response: {"id": 1, "name": "Alice", "bio": "Developer", "avatar": null}
# Include only specific fields
@app.get("/users/{user_id}/summary", response_model=UserOut,
response_model_include={"id", "name"})
async def get_user_summary(user_id: int):
return {"id": user_id, "name": "Alice", "email": "alice@example.com"}
# Response: {"id": 1, "name": "Alice"}
When validation fails, FastAPI automatically returns a 422 Unprocessable Entity response with detailed error information. You can customize this behavior to return errors in your preferred format.
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
class UserCreate(BaseModel):
name: str = Field(min_length=2, max_length=50)
age: int = Field(ge=0, le=150)
email: str = Field(pattern=r"^[\w\.-]+@[\w\.-]+\.\w{2,}$")
@app.post("/users")
async def create_user(user: UserCreate):
return user
# Sending invalid data returns 422 with:
# {
# "detail": [
# {
# "type": "string_too_short",
# "loc": ["body", "name"],
# "msg": "String should have at least 2 characters",
# "input": "A",
# "ctx": {"min_length": 2}
# },
# ...
# ]
# }
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from datetime import datetime
app = FastAPI()
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request,
exc: RequestValidationError
):
errors = []
for error in exc.errors():
field_path = " -> ".join(
str(loc) for loc in error["loc"] if loc != "body"
)
errors.append({
"field": field_path,
"message": error["msg"],
"type": error["type"],
"value": error.get("input"),
})
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"status": "error",
"message": "Validation failed",
"error_count": len(errors),
"errors": errors,
"timestamp": datetime.now().isoformat(),
"path": str(request.url)
}
)
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field
app = FastAPI()
# Simulated database
existing_usernames = {"alice", "bob", "admin"}
existing_emails = {"alice@example.com", "bob@example.com"}
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=30)
email: str
password: str = Field(min_length=8)
@app.post("/users", status_code=201)
async def create_user(user: UserCreate):
# Pydantic validates structure; we validate business rules
errors = []
if user.username.lower() in existing_usernames:
errors.append({
"field": "username",
"message": f"Username '{user.username}' is already taken"
})
if user.email.lower() in existing_emails:
errors.append({
"field": "email",
"message": f"Email '{user.email}' is already registered"
})
if errors:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"status": "error",
"message": "Registration failed",
"errors": errors
}
)
return {"status": "success", "message": f"User '{user.username}' created"}
from fastapi import FastAPI, Request, HTTPException, status
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
app = FastAPI()
class ErrorResponse:
@staticmethod
def build(status_code: int, message: str, errors: list = None,
path: str = None) -> dict:
return {
"status": "error",
"status_code": status_code,
"message": message,
"errors": errors or [],
"timestamp": datetime.now().isoformat(),
"path": path
}
# Handle Pydantic validation errors (422)
@app.exception_handler(RequestValidationError)
async def request_validation_handler(request: Request, exc: RequestValidationError):
errors = []
for error in exc.errors():
loc = [str(l) for l in error["loc"] if l != "body"]
errors.append({
"field": ".".join(loc) if loc else "unknown",
"message": error["msg"],
"type": error["type"]
})
return JSONResponse(
status_code=422,
content=ErrorResponse.build(422, "Request validation failed",
errors, str(request.url))
)
# Handle HTTP exceptions (400, 401, 403, 404, etc.)
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
return JSONResponse(
status_code=exc.status_code,
content=ErrorResponse.build(
exc.status_code,
str(exc.detail) if isinstance(exc.detail, str) else "HTTP Error",
[exc.detail] if isinstance(exc.detail, dict) else [],
str(request.url)
)
)
# Handle unexpected exceptions (500)
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
logger.error(f"Unhandled error: {exc}")
return JSONResponse(
status_code=500,
content=ErrorResponse.build(500, "An unexpected error occurred",
path=str(request.url))
)
Generic models let you create reusable response wrappers — like paginated responses, API envelopes, or standardized result types — that work with any data model. This is one of Pydantic’s most powerful features for building consistent APIs.
from pydantic import BaseModel
from typing import Generic, TypeVar, Optional, List
T = TypeVar("T")
class APIResponse(BaseModel, Generic[T]):
success: bool = True
message: str = "OK"
data: Optional[T] = None
class UserOut(BaseModel):
id: int
name: str
email: str
class ProductOut(BaseModel):
id: int
name: str
price: float
# Use with different types
user_response = APIResponse[UserOut](
data=UserOut(id=1, name="Alice", email="alice@example.com")
)
print(user_response.model_dump())
# {'success': True, 'message': 'OK',
# 'data': {'id': 1, 'name': 'Alice', 'email': 'alice@example.com'}}
product_response = APIResponse[ProductOut](
data=ProductOut(id=1, name="Laptop", price=999.99)
)
print(product_response.model_dump())
# Error response
error_response = APIResponse[None](
success=False, message="User not found", data=None
)
print(error_response.model_dump())
from pydantic import BaseModel, Field, computed_field
from typing import Generic, TypeVar, List, Optional
from math import ceil
T = TypeVar("T")
class PaginatedResponse(BaseModel, Generic[T]):
items: List[T]
total: int = Field(ge=0)
page: int = Field(ge=1)
page_size: int = Field(ge=1, le=100)
@computed_field
@property
def total_pages(self) -> int:
return ceil(self.total / self.page_size) if self.page_size else 0
@computed_field
@property
def has_next(self) -> bool:
return self.page < self.total_pages
@computed_field
@property
def has_previous(self) -> bool:
return self.page > 1
@computed_field
@property
def next_page(self) -> Optional[int]:
return self.page + 1 if self.has_next else None
@computed_field
@property
def previous_page(self) -> Optional[int]:
return self.page - 1 if self.has_previous else None
class UserOut(BaseModel):
id: int
name: str
email: str
# Usage in FastAPI
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/users", response_model=PaginatedResponse[UserOut])
async def list_users(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100)
):
all_users = [
{"id": i, "name": f"User {i}", "email": f"user{i}@example.com"}
for i in range(1, 96)
]
total = len(all_users)
start = (page - 1) * page_size
end = start + page_size
items = all_users[start:end]
return PaginatedResponse[UserOut](
items=items, total=total, page=page, page_size=page_size
)
# Response for page=2, page_size=20:
# {
# "items": [...],
# "total": 95,
# "page": 2,
# "page_size": 20,
# "total_pages": 5,
# "has_next": true,
# "has_previous": true,
# "next_page": 3,
# "previous_page": 1
# }
from pydantic import BaseModel, Field
from typing import Generic, TypeVar, Optional, List
from datetime import datetime
T = TypeVar("T")
class ErrorDetail(BaseModel):
field: Optional[str] = None
message: str
code: str
class APIEnvelope(BaseModel, Generic[T]):
success: bool = True
status_code: int = 200
message: str = "OK"
data: Optional[T] = None
errors: List[ErrorDetail] = []
meta: dict = {}
timestamp: datetime = Field(default_factory=datetime.now)
@classmethod
def ok(cls, data: T, message: str = "OK", meta: dict = None):
return cls(data=data, message=message, meta=meta or {})
@classmethod
def error(cls, status_code: int, message: str,
errors: List[ErrorDetail] = None):
return cls(
success=False, status_code=status_code,
message=message, errors=errors or []
)
@classmethod
def not_found(cls, resource: str, id):
return cls.error(404, f"{resource} with id {id} not found")
# Usage in FastAPI
from fastapi import FastAPI, HTTPException
app = FastAPI()
class UserOut(BaseModel):
id: int
name: str
email: str
@app.get("/users/{user_id}", response_model=APIEnvelope[UserOut])
async def get_user(user_id: int):
if user_id == 1:
return APIEnvelope.ok(
data=UserOut(id=1, name="Alice", email="alice@example.com"),
meta={"cache": "hit"}
)
raise HTTPException(status_code=404, detail=f"User {user_id} not found")
@app.get("/users", response_model=APIEnvelope[List[UserOut]])
async def list_users():
users = [
UserOut(id=1, name="Alice", email="alice@example.com"),
UserOut(id=2, name="Bob", email="bob@example.com"),
]
return APIEnvelope.ok(data=users, meta={"count": len(users)})
FastAPI handles file uploads and HTML form data differently from JSON bodies. You cannot use Pydantic’s BaseModel directly for form fields — instead, you use Form() and File() parameters with the same validation constraints.
from fastapi import FastAPI, Form, HTTPException
app = FastAPI()
@app.post("/login")
async def login(
username: str = Form(..., min_length=3, max_length=50),
password: str = Form(..., min_length=8),
remember_me: bool = Form(default=False),
):
# Accepts application/x-www-form-urlencoded or multipart/form-data
if username == "admin" and password == "Admin!234":
return {"status": "success", "username": username}
raise HTTPException(status_code=401, detail="Invalid credentials")
@app.post("/contact")
async def submit_contact_form(
name: str = Form(..., min_length=2, max_length=100),
email: str = Form(..., pattern=r"^[\w\.-]+@[\w\.-]+\.\w{2,}$"),
subject: str = Form(..., min_length=5, max_length=200),
message: str = Form(..., min_length=20, max_length=5000),
category: str = Form(default="general"),
priority: int = Form(default=3, ge=1, le=5),
):
return {
"status": "submitted",
"contact": {
"name": name,
"email": email,
"subject": subject,
"message_length": len(message),
"category": category,
"priority": priority,
}
}
from fastapi import FastAPI, File, UploadFile, HTTPException, status
from typing import List
app = FastAPI()
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
ALLOWED_DOC_TYPES = {"application/pdf", "text/plain", "text/csv"}
def validate_image(file: UploadFile) -> None:
if file.content_type not in ALLOWED_IMAGE_TYPES:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail=f"Type '{file.content_type}' not allowed. "
f"Allowed: {', '.join(ALLOWED_IMAGE_TYPES)}"
)
@app.post("/upload/avatar")
async def upload_avatar(file: UploadFile = File(...)):
validate_image(file)
contents = await file.read()
size = len(contents)
if size > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File too large. Max: {MAX_FILE_SIZE // (1024*1024)}MB"
)
await file.seek(0)
return {
"filename": file.filename,
"content_type": file.content_type,
"size_bytes": size,
"size_mb": round(size / (1024 * 1024), 2)
}
@app.post("/upload/documents")
async def upload_documents(
files: List[UploadFile] = File(..., description="Max 10 files")
):
if len(files) > 10:
raise HTTPException(400, "Maximum 10 files allowed")
results = []
errors = []
allowed = ALLOWED_IMAGE_TYPES | ALLOWED_DOC_TYPES
for idx, file in enumerate(files):
if file.content_type not in allowed:
errors.append({"file": file.filename, "error": "Type not allowed"})
continue
contents = await file.read()
if len(contents) > MAX_FILE_SIZE:
errors.append({"file": file.filename, "error": "Too large"})
continue
results.append({
"filename": file.filename,
"content_type": file.content_type,
"size_bytes": len(contents)
})
return {"uploaded": len(results), "failed": len(errors),
"files": results, "errors": errors}
from fastapi import FastAPI, File, Form, UploadFile, HTTPException
from typing import List
app = FastAPI()
@app.post("/products")
async def create_product_with_image(
# Form fields
name: str = Form(..., min_length=1, max_length=200),
description: str = Form(default="", max_length=5000),
price: float = Form(..., gt=0),
category: str = Form(..., min_length=1),
tags: str = Form(default=""), # comma-separated
# File fields
main_image: UploadFile = File(...),
additional_images: List[UploadFile] = File(default=[]),
):
# When mixing Form and File, request must use multipart/form-data
if main_image.content_type not in {"image/jpeg", "image/png", "image/webp"}:
raise HTTPException(400, "Main image must be JPEG, PNG, or WebP")
if len(additional_images) > 5:
raise HTTPException(400, "Maximum 5 additional images")
tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else []
main_size = len(await main_image.read())
additional_info = []
for img in additional_images:
content = await img.read()
additional_info.append({
"filename": img.filename, "size": len(content)
})
return {
"product": {
"name": name, "description": description,
"price": price, "category": category, "tags": tag_list
},
"images": {
"main": {"filename": main_image.filename, "size": main_size},
"additional": additional_info
}
}
# Test with curl: curl -X POST http://localhost:8000/products \ -F "name=Wireless Mouse" \ -F "price=29.99" \ -F "category=electronics" \ -F "tags=sale,featured" \ -F "main_image=@mouse.jpg" \ -F "additional_images=@mouse_side.jpg"
Let us bring everything together in a comprehensive user registration API with multi-step validation, address handling, password strength checking, and email verification.
registration_system/ ├── main.py # FastAPI application and routes ├── models/ │ ├── __init__.py │ ├── user.py # User Pydantic models │ ├── address.py # Address models with validation │ ├── common.py # Shared/generic models │ └── validators.py # Reusable validators ├── services/ │ ├── __init__.py │ └── user_service.py # Business logic └── requirements.txt
# models/validators.py
import re
from typing import Annotated
from pydantic import AfterValidator
def validate_password_strength(password: str) -> str:
if len(password) < 8:
raise ValueError("Password must be at least 8 characters")
if len(password) > 128:
raise ValueError("Password must not exceed 128 characters")
if not re.search(r"[A-Z]", password):
raise ValueError("Must contain at least one uppercase letter")
if not re.search(r"[a-z]", password):
raise ValueError("Must contain at least one lowercase letter")
if not re.search(r"\d", password):
raise ValueError("Must contain at least one digit")
if not re.search(r"[!@#$%^&*()_+\-=\[\]{}|;:,./?\\\"]", password):
raise ValueError("Must contain at least one special character")
common_patterns = ["password", "12345678", "qwerty", "abc123"]
if password.lower() in common_patterns:
raise ValueError("Password is too common")
return password
def validate_phone_number(phone: str) -> str:
cleaned = phone.strip()
if cleaned.startswith("+"):
digits = "+" + re.sub(r"\D", "", cleaned[1:])
else:
digits = re.sub(r"\D", "", cleaned)
if not digits.startswith("+"):
if len(digits) == 10:
digits = "+1" + digits
elif len(digits) == 11 and digits.startswith("1"):
digits = "+" + digits
else:
raise ValueError("Invalid phone number format")
digit_count = len(digits.replace("+", ""))
if digit_count < 10 or digit_count > 15:
raise ValueError("Phone number must be 10-15 digits")
return digits
def validate_username(username: str) -> str:
username = username.strip().lower()
if len(username) < 3:
raise ValueError("Username must be at least 3 characters")
if len(username) > 30:
raise ValueError("Username must not exceed 30 characters")
if not re.match(r"^[a-z][a-z0-9_]*$", username):
raise ValueError("Must start with letter; only lowercase, numbers, underscores")
if "__" in username:
raise ValueError("No consecutive underscores")
reserved = {"admin", "root", "system", "api", "null", "undefined", "support"}
if username in reserved:
raise ValueError(f"Username '{username}' is reserved")
return username
# Create reusable annotated types
StrongPassword = Annotated[str, AfterValidator(validate_password_strength)]
PhoneNumber = Annotated[str, AfterValidator(validate_phone_number)]
Username = Annotated[str, AfterValidator(validate_username)]
# models/address.py
from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Optional, Literal
US_STATES = {
"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA",
"HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", "MD",
"MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ",
"NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC",
"SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY",
"DC", "PR", "VI", "GU", "AS", "MP"
}
class Address(BaseModel):
street_line_1: str = Field(min_length=1, max_length=200)
street_line_2: Optional[str] = Field(default=None, max_length=200)
city: str = Field(min_length=1, max_length=100)
state: str = Field(min_length=2, max_length=2)
zip_code: str = Field(pattern=r"^\d{5}(-\d{4})?$")
country: Literal["US"] = "US"
@field_validator("state")
@classmethod
def validate_state(cls, v: str) -> str:
v = v.upper().strip()
if v not in US_STATES:
raise ValueError(f"Invalid US state code: {v}")
return v
@field_validator("city")
@classmethod
def normalize_city(cls, v: str) -> str:
return v.strip().title()
class AddressCreate(Address):
address_type: Literal["home", "work", "billing", "shipping"] = "home"
is_primary: bool = False
@model_validator(mode="after")
def validate_po_box(self):
if self.address_type == "shipping":
street = self.street_line_1.lower()
if "po box" in street or "p.o. box" in street:
raise ValueError("Shipping address cannot be a PO Box")
return self
# models/user.py
from pydantic import (
BaseModel, Field, ConfigDict, field_validator,
model_validator, computed_field
)
from typing import Optional, List, Annotated
from datetime import date, datetime
from enum import Enum
from pydantic import AfterValidator
import re
# Inline password validator for self-contained example
def _validate_password(p: str) -> str:
if len(p) < 8:
raise ValueError("Password must be at least 8 characters")
if not re.search(r"[A-Z]", p):
raise ValueError("Must contain uppercase letter")
if not re.search(r"[a-z]", p):
raise ValueError("Must contain lowercase letter")
if not re.search(r"\d", p):
raise ValueError("Must contain digit")
if not re.search(r"[!@#$%^&*()\-_=+]", p):
raise ValueError("Must contain special character")
return p
StrongPassword = Annotated[str, AfterValidator(_validate_password)]
class Gender(str, Enum):
MALE = "male"
FEMALE = "female"
NON_BINARY = "non_binary"
PREFER_NOT_TO_SAY = "prefer_not_to_say"
class NotificationPreference(str, Enum):
EMAIL = "email"
SMS = "sms"
PUSH = "push"
NONE = "none"
class UserRegistration(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
# Account Info
username: str = Field(
min_length=3, max_length=30,
pattern=r"^[a-zA-Z][a-zA-Z0-9_]*$"
)
email: str = Field(pattern=r"^[\w\.-]+@[\w\.-]+\.\w{2,}$")
password: StrongPassword
confirm_password: str
# Personal Info
first_name: str = Field(min_length=1, max_length=50)
last_name: str = Field(min_length=1, max_length=50)
date_of_birth: date
gender: Optional[Gender] = None
phone: Optional[str] = Field(default=None, pattern=r"^\+?1?\d{10,15}$")
# Preferences
notifications: List[NotificationPreference] = Field(
default=[NotificationPreference.EMAIL]
)
newsletter: bool = False
terms_accepted: bool
@field_validator("username")
@classmethod
def normalize_username(cls, v: str) -> str:
return v.lower()
@field_validator("email")
@classmethod
def normalize_email(cls, v: str) -> str:
return v.lower()
@field_validator("first_name", "last_name")
@classmethod
def capitalize_name(cls, v: str) -> str:
return v.strip().title()
@field_validator("date_of_birth")
@classmethod
def validate_age(cls, v: date) -> date:
today = date.today()
age = today.year - v.year - ((today.month, today.day) < (v.month, v.day))
if age < 13:
raise ValueError("Must be at least 13 years old")
if age > 120:
raise ValueError("Invalid date of birth")
return v
@field_validator("terms_accepted")
@classmethod
def must_accept_terms(cls, v: bool) -> bool:
if not v:
raise ValueError("You must accept the terms and conditions")
return v
@model_validator(mode="after")
def validate_registration(self):
if self.password != self.confirm_password:
raise ValueError("Passwords do not match")
if self.username.lower() in self.password.lower():
raise ValueError("Password must not contain your username")
if NotificationPreference.SMS in self.notifications and not self.phone:
raise ValueError("Phone number required for SMS notifications")
return self
@computed_field
@property
def display_name(self) -> str:
return f"{self.first_name} {self.last_name}"
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
email: str
first_name: str
last_name: str
display_name: str
date_of_birth: date
gender: Optional[Gender]
phone: Optional[str]
notifications: List[NotificationPreference]
is_active: bool
is_verified: bool
created_at: datetime
class UserUpdate(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
first_name: Optional[str] = Field(default=None, min_length=1, max_length=50)
last_name: Optional[str] = Field(default=None, min_length=1, max_length=50)
phone: Optional[str] = Field(default=None, pattern=r"^\+?1?\d{10,15}$")
gender: Optional[Gender] = None
notifications: Optional[List[NotificationPreference]] = None
newsletter: Optional[bool] = None
# main.py
from fastapi import FastAPI, HTTPException, Request, Query, Path, status
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from datetime import datetime
from typing import Optional
import secrets
import re
app = FastAPI(
title="User Registration System",
description="Complete registration with Pydantic validation",
version="1.0.0"
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = []
for error in exc.errors():
loc = [str(l) for l in error["loc"] if l != "body"]
errors.append({
"field": ".".join(loc) if loc else "general",
"message": error["msg"],
"type": error["type"]
})
return JSONResponse(
status_code=422,
content={
"success": False, "message": "Validation failed",
"errors": errors, "timestamp": datetime.now().isoformat()
}
)
# Simulated Database
users_db: dict = {}
next_user_id = 1
email_verifications: dict = {}
@app.post("/api/v1/register", status_code=201)
async def register_user(registration):
global next_user_id
for user in users_db.values():
if user["username"] == registration.username:
raise HTTPException(409, {"field": "username", "message": "Already taken"})
if user["email"] == registration.email:
raise HTTPException(409, {"field": "email", "message": "Already registered"})
user_data = {
"id": next_user_id,
"username": registration.username,
"email": registration.email,
"first_name": registration.first_name,
"last_name": registration.last_name,
"display_name": registration.display_name,
"date_of_birth": registration.date_of_birth.isoformat(),
"gender": registration.gender.value if registration.gender else None,
"phone": registration.phone,
"notifications": [n.value for n in registration.notifications],
"is_active": True,
"is_verified": False,
"created_at": datetime.now().isoformat()
}
users_db[next_user_id] = user_data
token = secrets.token_urlsafe(32)
email_verifications[token] = next_user_id
next_user_id += 1
return {
"success": True,
"message": "Registration successful! Check your email to verify.",
"data": {
"id": user_data["id"],
"username": user_data["username"],
"email": user_data["email"],
"display_name": user_data["display_name"]
}
}
@app.get("/api/v1/verify-email/{token}")
async def verify_email(token: str):
user_id = email_verifications.get(token)
if not user_id or user_id not in users_db:
raise HTTPException(400, "Invalid or expired verification token")
users_db[user_id]["is_verified"] = True
del email_verifications[token]
return {"success": True, "message": "Email verified successfully!"}
@app.get("/api/v1/users/{user_id}")
async def get_user(user_id: int = Path(..., gt=0)):
user = users_db.get(user_id)
if not user:
raise HTTPException(404, f"User {user_id} not found")
return {"success": True, "data": user}
@app.get("/api/v1/users")
async def list_users(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
search: Optional[str] = Query(default=None, min_length=1),
):
filtered = list(users_db.values())
if search:
s = search.lower()
filtered = [u for u in filtered
if s in u["username"] or s in u["email"]
or s in u["display_name"].lower()]
total = len(filtered)
start = (page - 1) * page_size
items = filtered[start:start + page_size]
return {
"success": True,
"data": {
"items": items, "total": total,
"page": page, "page_size": page_size,
"total_pages": -(-total // page_size)
}
}
@app.get("/api/v1/check-username/{username}")
async def check_username(username: str = Path(..., min_length=3, max_length=30)):
taken = any(u["username"] == username.lower() for u in users_db.values())
return {"username": username.lower(), "available": not taken}
@app.get("/api/v1/password-strength")
async def check_password_strength(
password: str = Query(..., min_length=1)
):
score = 0
feedback = []
if len(password) >= 8: score += 1
else: feedback.append("Use at least 8 characters")
if len(password) >= 12: score += 1
if len(password) >= 16: score += 1
if re.search(r"[A-Z]", password): score += 1
else: feedback.append("Add an uppercase letter")
if re.search(r"[a-z]", password): score += 1
else: feedback.append("Add a lowercase letter")
if re.search(r"\d", password): score += 1
else: feedback.append("Add a number")
if re.search(r"[!@#$%^&*()\-_=+]", password): score += 1
else: feedback.append("Add a special character")
if score <= 2: strength = "weak"
elif score <= 4: strength = "fair"
elif score <= 5: strength = "good"
else: strength = "strong"
return {
"score": score, "max_score": 7, "strength": strength,
"feedback": feedback, "meets_requirements": score >= 4
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
# Start the server
uvicorn main:app --reload
# 1. Check username availability
curl http://localhost:8000/api/v1/check-username/alice_dev
# 2. Check password strength
curl "http://localhost:8000/api/v1/password-strength?password=Str0ng!Pass"
# 3. Register a new user
curl -X POST http://localhost:8000/api/v1/register \
-H "Content-Type: application/json" \
-d '{
"username": "alice_dev",
"email": "alice@example.com",
"password": "Str0ng!Pass#1",
"confirm_password": "Str0ng!Pass#1",
"first_name": "Alice",
"last_name": "Johnson",
"date_of_birth": "1995-03-15",
"gender": "female",
"phone": "+15551234567",
"notifications": ["email", "sms"],
"newsletter": true,
"terms_accepted": true
}'
# 4. Try invalid registration (triggers multiple errors)
curl -X POST http://localhost:8000/api/v1/register \
-H "Content-Type: application/json" \
-d '{
"username": "a",
"email": "not-email",
"password": "weak",
"confirm_password": "different",
"first_name": "",
"last_name": "Johnson",
"date_of_birth": "2020-01-01",
"terms_accepted": false
}'
# 5. List users
curl "http://localhost:8000/api/v1/users?page=1&page_size=10"
# 6. Get specific user
curl http://localhost:8000/api/v1/users/1
Here is a summary of everything you have learned about Pydantic models and validation in FastAPI:
| Topic | Key Points |
|---|---|
| BaseModel | Inherit from BaseModel, define fields with type annotations, use defaults and Optional |
| Field() | Add constraints: min_length, max_length, gt, lt, ge, le, pattern, multiple_of |
| Type Annotations | Use str, int, float, bool, List, Dict, Optional, Union, Literal, Annotated |
| Nested Models | Compose models within models; validation is recursive and automatic |
| @field_validator | Custom validation for individual fields; use mode="before" for pre-processing |
| @model_validator | Cross-field validation after all fields are set; use for password confirmation, date ranges |
| @computed_field | Derived values included in serialization; use with @property |
| model_config | extra="forbid", str_strip_whitespace, from_attributes, frozen |
| Serialization | model_dump() with exclude, include, exclude_none, exclude_unset, by_alias |
| Request Validation | Body, Query, Path, Header, Cookie — all validated automatically by FastAPI |
| Response Models | response_model filters output; use different input/output schemas to protect sensitive data |
| Error Handling | Customize RequestValidationError handler for consistent API error format |
| Generic Models | Generic[T] for reusable wrappers: paginated responses, API envelopes |
| Form & Files | Use Form() and File() for non-JSON input; cannot use BaseModel directly |
extra="forbid" on input models to catch typos and unexpected fieldsAnnotated types for reusable validation logic across models@field_validator and cross-field checks in @model_validatormodel_dump(exclude_unset=True) for PATCH endpoints to only update provided fieldsfrom_attributes=True when working with SQLAlchemy ORM objectsIn the next tutorial, FastAPI – Testing, you will learn how to write comprehensive tests for all of these validation rules using pytest and FastAPI’s TestClient. You will see how to test valid inputs, invalid inputs, edge cases, and custom error responses — ensuring your validation logic is bulletproof.
Database integration is the backbone of any production FastAPI application. In this tutorial, we will cover everything from basic SQLAlchemy setup to advanced patterns like the Repository pattern, async database access, connection pooling, and Alembic migrations. By the end, you will have built a complete e-commerce data layer with full CRUD operations, relationships, and migration support.
FastAPI is database-agnostic — it does not ship with a built-in ORM or database layer. This gives you the freedom to choose the best database and toolkit for your project. Here are the most common options:
SQLite is a file-based relational database that requires zero configuration. It is perfect for development, prototyping, and small applications. Python includes SQLite support in the standard library.
# SQLite connection string examples SQLITE_URL = "sqlite:///./app.db" # File-based SQLITE_URL_MEMORY = "sqlite:///:memory:" # In-memory (testing) SQLITE_URL_ABSOLUTE = "sqlite:////tmp/app.db" # Absolute path
Pros: Zero setup, no server needed, great for testing and development.
Cons: Not suitable for high-concurrency production workloads, limited to a single writer at a time.
PostgreSQL is the most popular choice for production FastAPI applications. It offers advanced features like JSONB columns, full-text search, array types, and excellent concurrency handling.
# PostgreSQL connection strings POSTGRES_URL = "postgresql://user:password@localhost:5432/mydb" POSTGRES_ASYNC_URL = "postgresql+asyncpg://user:password@localhost:5432/mydb" POSTGRES_PSYCOPG2 = "postgresql+psycopg2://user:password@localhost:5432/mydb"
Pros: Production-ready, advanced features, excellent ecosystem, strong ACID compliance.
Cons: Requires a running server, more complex setup than SQLite.
MySQL is another widely-used relational database with a large community and extensive tooling support.
# MySQL connection strings MYSQL_URL = "mysql+pymysql://user:password@localhost:3306/mydb" MYSQL_ASYNC_URL = "mysql+aiomysql://user:password@localhost:3306/mydb"
Pros: Widely supported, good performance, large community.
Cons: Fewer advanced features compared to PostgreSQL, some SQL standard deviations.
MongoDB is a document-oriented NoSQL database. While not used with SQLAlchemy, it works well with FastAPI through libraries like Motor (async) or PyMongo.
# MongoDB with Motor (async)
from motor.motor_asyncio import AsyncIOMotorClient
client = AsyncIOMotorClient("mongodb://localhost:27017")
db = client["mydb"]
collection = db["users"]
# Insert a document
await collection.insert_one({"name": "John", "email": "john@example.com"})
# Find documents
async for doc in collection.find({"name": "John"}):
print(doc)
Pros: Flexible schema, great for unstructured data, horizontal scaling.
Cons: No ACID transactions across documents (by default), less suited for relational data.
| Feature | SQLite | PostgreSQL | MySQL | MongoDB |
|---|---|---|---|---|
| Type | Relational (file) | Relational (server) | Relational (server) | Document (NoSQL) |
| Setup Complexity | None | Medium | Medium | Medium |
| Concurrency | Low | High | High | High |
| ACID Compliance | Yes | Yes | Yes (InnoDB) | Partial |
| Async Support | aiosqlite | asyncpg | aiomysql | Motor |
| Best For | Dev/Testing | Production APIs | Production APIs | Flexible schemas |
| SQLAlchemy Support | Full | Full | Full | No (use ODM) |
| JSON Support | Basic | JSONB (excellent) | JSON type | Native |
SQLAlchemy is the most popular ORM (Object-Relational Mapper) for Python. It provides a powerful and flexible way to interact with relational databases. FastAPI works seamlessly with SQLAlchemy through its dependency injection system.
# Core dependencies pip install fastapi uvicorn sqlalchemy # Database drivers pip install aiosqlite # SQLite async driver pip install psycopg2-binary # PostgreSQL sync driver pip install asyncpg # PostgreSQL async driver pip install pymysql # MySQL sync driver pip install aiomysql # MySQL async driver # Migration tool pip install alembic # Install all at once for this tutorial pip install fastapi uvicorn sqlalchemy alembic psycopg2-binary
Let us set up a well-organized project structure that separates concerns properly:
fastapi-database-tutorial/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI application entry point
│ ├── config.py # Configuration settings
│ ├── database.py # Database engine, session, base
│ ├── models/
│ │ ├── __init__.py
│ │ ├── user.py # User model
│ │ ├── product.py # Product model
│ │ └── order.py # Order model
│ ├── schemas/
│ │ ├── __init__.py
│ │ ├── user.py # User Pydantic schemas
│ │ ├── product.py # Product Pydantic schemas
│ │ └── order.py # Order Pydantic schemas
│ ├── repositories/
│ │ ├── __init__.py
│ │ ├── base.py # Base repository
│ │ └── user.py # User repository
│ ├── routers/
│ │ ├── __init__.py
│ │ ├── users.py # User routes
│ │ └── products.py # Product routes
│ └── dependencies.py # Shared dependencies
├── alembic/
│ ├── versions/ # Migration files
│ ├── env.py # Alembic environment
│ └── script.py.mako # Migration template
├── alembic.ini # Alembic configuration
├── requirements.txt
└── tests/
├── __init__.py
└── test_users.py
First, create a configuration module that handles environment-specific settings:
# app/config.py
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# Database
DATABASE_URL: str = "sqlite:///./app.db"
DATABASE_ECHO: bool = False # Log SQL queries
# Application
APP_NAME: str = "FastAPI Database Tutorial"
DEBUG: bool = True
# PostgreSQL production example:
# DATABASE_URL: str = "postgresql://user:pass@localhost:5432/mydb"
class Config:
env_file = ".env"
case_sensitive = True
@lru_cache
def get_settings() -> Settings:
"""Cache settings to avoid reading .env on every request."""
return Settings()
The database module is the foundation of your data layer. It creates the engine, configures sessions, and provides the declarative base for your models:
# app/database.py
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from app.config import get_settings
settings = get_settings()
# Create the SQLAlchemy engine
# The engine manages a pool of database connections
engine = create_engine(
settings.DATABASE_URL,
echo=settings.DATABASE_ECHO, # Log SQL statements
# SQLite-specific: allow multi-threaded access
connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {},
# Connection pool settings (for non-SQLite databases)
pool_size=10, # Number of persistent connections
max_overflow=20, # Additional connections when pool is full
pool_timeout=30, # Seconds to wait for a connection
pool_recycle=1800, # Recycle connections after 30 minutes
pool_pre_ping=True, # Verify connections before using them
)
# Enable foreign keys for SQLite (disabled by default)
if "sqlite" in settings.DATABASE_URL:
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
# Create a session factory
# Each request gets its own session
SessionLocal = sessionmaker(
autocommit=False, # We manage transactions explicitly
autoflush=False, # We flush manually for control
bind=engine,
)
# SQLAlchemy 2.0 style declarative base
class Base(DeclarativeBase):
"""Base class for all database models."""
pass
def create_tables():
"""Create all tables in the database.
Used for development; use Alembic migrations in production.
"""
Base.metadata.create_all(bind=engine)
check_same_thread=False argument is only needed for SQLite. FastAPI runs in multiple threads, and SQLite by default only allows access from the thread that created the connection. This setting disables that check. For PostgreSQL or MySQL, this argument is not needed.The SQLAlchemy engine is the starting point for all database operations. Here is what each configuration option does:
# Detailed engine configuration explained
from sqlalchemy import create_engine
engine = create_engine(
"postgresql://user:pass@localhost:5432/mydb",
# echo=True prints all SQL to stdout - useful for debugging
echo=True,
# pool_size: Number of connections to keep open in the pool
# Default is 5. Increase for high-traffic applications.
pool_size=10,
# max_overflow: Extra connections allowed beyond pool_size
# These are created on-demand and closed when returned to pool
max_overflow=20,
# pool_timeout: Seconds to wait for a connection from the pool
# Raises TimeoutError if no connection is available
pool_timeout=30,
# pool_recycle: Seconds before a connection is recycled
# Prevents "MySQL has gone away" type errors
pool_recycle=1800,
# pool_pre_ping: Test connections before using them
# Slightly slower but prevents using stale connections
pool_pre_ping=True,
# execution_options: Default options for all executions
execution_options={
"isolation_level": "REPEATABLE READ"
},
)
Database models define the structure of your tables. SQLAlchemy 2.0 uses Mapped type annotations for a more Pythonic and type-safe approach to defining columns and relationships.
# app/models/user.py
from datetime import datetime
from typing import Optional, List
from sqlalchemy import String, Integer, Boolean, DateTime, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class User(Base):
"""User model representing the users table."""
__tablename__ = "users"
# Primary key - auto-incrementing integer
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Required fields
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
# Optional fields
full_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
bio: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
# Relationships
posts: Mapped[List["Post"]] = relationship(
"Post", back_populates="author", cascade="all, delete-orphan"
)
orders: Mapped[List["Order"]] = relationship(
"Order", back_populates="user", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<User(id={self.id}, username='{self.username}', email='{self.email}')>"
SQLAlchemy provides a rich set of column types. Here are the most commonly used ones:
| SQLAlchemy Type | Python Type | SQL Type | Description |
|---|---|---|---|
| Integer | int | INTEGER | Standard integer |
| BigInteger | int | BIGINT | Large integer (IDs in big tables) |
| String(n) | str | VARCHAR(n) | Variable-length string with max length |
| Text | str | TEXT | Unlimited length text |
| Boolean | bool | BOOLEAN | True/False values |
| Float | float | FLOAT | Floating-point number |
| Numeric(p, s) | Decimal | NUMERIC | Exact decimal (use for money) |
| DateTime | datetime | DATETIME | Date and time |
| Date | date | DATE | Date only |
| Time | time | TIME | Time only |
| JSON | dict/list | JSON | JSON data |
| LargeBinary | bytes | BLOB | Binary data |
| Enum | enum.Enum | ENUM/VARCHAR | Enumerated values |
| UUID | uuid.UUID | UUID/CHAR(36) | Universally unique identifier |
One-to-many is the most common relationship type. One user can have many posts, but each post belongs to exactly one user.
# app/models/post.py
from datetime import datetime
from typing import Optional, List
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, func, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
import enum
class PostStatus(str, enum.Enum):
DRAFT = "draft"
PUBLISHED = "published"
ARCHIVED = "archived"
class Post(Base):
"""Post model with a many-to-one relationship to User."""
__tablename__ = "posts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
slug: Mapped[str] = mapped_column(String(200), unique=True, nullable=False, index=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[PostStatus] = mapped_column(
SAEnum(PostStatus), default=PostStatus.DRAFT, nullable=False
)
view_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
# Foreign key - links to users table
author_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
published_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Relationship back to User
author: Mapped["User"] = relationship("User", back_populates="posts")
# Many-to-many with tags
tags: Mapped[List["Tag"]] = relationship(
"Tag", secondary="post_tags", back_populates="posts"
)
def __repr__(self) -> str:
return f"<Post(id={self.id}, title='{self.title}', status='{self.status}')>"
Many-to-many relationships require an association table. A post can have many tags, and a tag can be applied to many posts.
# app/models/tag.py
from typing import List
from sqlalchemy import String, Integer, Table, Column, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
# Association table for many-to-many relationship
# This is a simple link table with no extra columns
post_tags = Table(
"post_tags",
Base.metadata,
Column("post_id", Integer, ForeignKey("posts.id", ondelete="CASCADE"), primary_key=True),
Column("tag_id", Integer, ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
)
class Tag(Base):
"""Tag model with many-to-many relationship to Post."""
__tablename__ = "tags"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
slug: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
# Many-to-many relationship
posts: Mapped[List["Post"]] = relationship(
"Post", secondary=post_tags, back_populates="tags"
)
def __repr__(self) -> str:
return f"<Tag(id={self.id}, name='{self.name}')>"
When you need extra columns on the association table (like quantity or created date), use an association object pattern:
# app/models/order.py
from datetime import datetime
from typing import List
from sqlalchemy import (
String, Integer, Numeric, DateTime, ForeignKey, func, Enum as SAEnum
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
import enum
class OrderStatus(str, enum.Enum):
PENDING = "pending"
CONFIRMED = "confirmed"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
class Order(Base):
"""Order model."""
__tablename__ = "orders"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
order_number: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
status: Mapped[OrderStatus] = mapped_column(
SAEnum(OrderStatus), default=OrderStatus.PENDING
)
total_amount: Mapped[float] = mapped_column(Numeric(10, 2), default=0)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
# Relationships
user: Mapped["User"] = relationship("User", back_populates="orders")
items: Mapped[List["OrderItem"]] = relationship(
"OrderItem", back_populates="order", cascade="all, delete-orphan"
)
class OrderItem(Base):
"""Association object between Order and Product with extra data."""
__tablename__ = "order_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Composite foreign keys
order_id: Mapped[int] = mapped_column(
Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False
)
product_id: Mapped[int] = mapped_column(
Integer, ForeignKey("products.id", ondelete="RESTRICT"), nullable=False
)
# Extra data on the association
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
unit_price: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
total_price: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
# Relationships
order: Mapped["Order"] = relationship("Order", back_populates="items")
product: Mapped["Product"] = relationship("Product")
Proper indexing is essential for query performance. Here is how to define various types of indexes and constraints:
# app/models/product.py
from datetime import datetime
from typing import Optional
from sqlalchemy import (
String, Integer, Numeric, Text, Boolean, DateTime,
ForeignKey, Index, UniqueConstraint, CheckConstraint, func
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Product(Base):
"""Product model demonstrating various indexes and constraints."""
__tablename__ = "products"
# Table-level constraints and indexes
__table_args__ = (
# Composite unique constraint
UniqueConstraint("name", "category_id", name="uq_product_name_category"),
# Composite index for common queries
Index("ix_product_category_price", "category_id", "price"),
# Partial index (PostgreSQL only)
# Index("ix_active_products", "name", postgresql_where=text("is_active = true")),
# Check constraint
CheckConstraint("price >= 0", name="ck_product_price_positive"),
CheckConstraint("stock_quantity >= 0", name="ck_product_stock_positive"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
slug: Mapped[str] = mapped_column(String(200), unique=True, nullable=False, index=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
price: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
stock_quantity: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
sku: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Foreign key to category
category_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
# Relationships
category: Mapped[Optional["Category"]] = relationship("Category", back_populates="products")
def __repr__(self) -> str:
return f"<Product(id={self.id}, name='{self.name}', price={self.price})>"
class Category(Base):
"""Category model with self-referential relationship for nested categories."""
__tablename__ = "categories"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Self-referential foreign key for parent category
parent_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
)
# Self-referential relationship
parent: Mapped[Optional["Category"]] = relationship(
"Category", remote_side="Category.id", back_populates="children"
)
children: Mapped[list["Category"]] = relationship(
"Category", back_populates="parent"
)
# Products in this category
products: Mapped[list["Product"]] = relationship(
"Product", back_populates="category"
)
def __repr__(self) -> str:
return f"<Category(id={self.id}, name='{self.name}')>"
FastAPI’s dependency injection system is perfect for managing database sessions. Each request gets its own session that is automatically cleaned up when the request completes, regardless of whether it succeeded or raised an exception.
# app/dependencies.py
from typing import Generator, Annotated
from sqlalchemy.orm import Session
from fastapi import Depends
from app.database import SessionLocal
def get_db() -> Generator[Session, None, None]:
"""
Dependency that provides a database session per request.
Uses a generator with yield to ensure the session is always
closed after the request completes, even if an error occurs.
Usage:
@app.get("/users")
def get_users(db: Session = Depends(get_db)):
return db.query(User).all()
"""
db = SessionLocal()
try:
yield db
finally:
db.close()
# Type alias for cleaner route signatures
DbSession = Annotated[Session, Depends(get_db)]
For a more robust approach, you can create a dependency that automatically commits on success and rolls back on failure:
# app/dependencies.py (enhanced version)
from typing import Generator, Annotated
from sqlalchemy.orm import Session
from fastapi import Depends
from app.database import SessionLocal
import logging
logger = logging.getLogger(__name__)
def get_db() -> Generator[Session, None, None]:
"""
Provides a transactional database session.
- Commits automatically if no exceptions occur
- Rolls back automatically on exceptions
- Always closes the session when done
"""
db = SessionLocal()
try:
yield db
db.commit() # Commit if everything went well
except Exception:
db.rollback() # Rollback on any exception
raise
finally:
db.close()
# Reusable type annotation
DbSession = Annotated[Session, Depends(get_db)]
db.commit() explicitly in their route handlers or service layer. Choose the approach that fits your team’s conventions.
# app/routers/users.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.dependencies import DbSession
from app.models.user import User
from app.schemas.user import UserCreate, UserResponse
router = APIRouter(prefix="/users", tags=["users"])
# Method 1: Using the type alias (recommended)
@router.get("/", response_model=list[UserResponse])
def get_users(db: DbSession, skip: int = 0, limit: int = 100):
"""Get all users with pagination."""
users = db.query(User).offset(skip).limit(limit).all()
return users
# Method 2: Using Depends directly
@router.get("/{user_id}", response_model=UserResponse)
def get_user(user_id: int, db: Session = Depends(get_db)):
"""Get a single user by ID."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with id {user_id} not found"
)
return user
Understanding the session lifecycle is critical for avoiding common bugs:
# Session lifecycle visualization
"""
Request comes in
│
▼
get_db() creates SessionLocal() ← New session created
│
▼
yield db ← Session available to route handler
│
▼
Route handler executes ← Queries run on this session
│ Objects are tracked by session
▼
Request completes (or fails)
│
▼
finally: db.close() ← Session returned to pool
Objects become "detached"
"""
# Common mistake: accessing lazy-loaded relationships after session closes
@router.get("/users/{user_id}")
def get_user_bad(user_id: int, db: DbSession):
user = db.query(User).filter(User.id == user_id).first()
db.close() # DON'T DO THIS - let the dependency handle it!
# user.posts # This would fail! Session is closed.
return user
# Correct approach: let the dependency manage the session lifecycle
@router.get("/users/{user_id}")
def get_user_good(user_id: int, db: DbSession):
user = db.query(User).filter(User.id == user_id).first()
# Access relationships while session is still open
_ = user.posts # This works because session is still active
return user
CRUD (Create, Read, Update, Delete) operations form the core of any data-driven application. Let us implement each operation with proper error handling and best practices.
# app/routers/users.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from app.dependencies import DbSession
from app.models.user import User
from app.schemas.user import UserCreate, UserResponse
import hashlib
router = APIRouter(prefix="/users", tags=["users"])
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(user_data: UserCreate, db: DbSession):
"""
Create a new user.
Steps:
1. Validate input (handled by Pydantic)
2. Check for existing user
3. Create model instance
4. Add to session
5. Commit transaction
6. Refresh to get generated values (id, timestamps)
"""
# Check if user already exists
existing_user = db.query(User).filter(
(User.email == user_data.email) | (User.username == user_data.username)
).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User with this email or username already exists"
)
# Create new user instance
db_user = User(
username=user_data.username,
email=user_data.email,
hashed_password=hashlib.sha256(user_data.password.encode()).hexdigest(),
full_name=user_data.full_name,
)
try:
db.add(db_user) # Add to session (pending state)
db.commit() # Write to database
db.refresh(db_user) # Reload from DB to get generated values
return db_user
except IntegrityError:
db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User with this email or username already exists"
)
@router.post("/bulk", response_model=list[UserResponse], status_code=status.HTTP_201_CREATED)
def create_users_bulk(users_data: list[UserCreate], db: DbSession):
"""Create multiple users in a single transaction."""
db_users = []
for user_data in users_data:
db_user = User(
username=user_data.username,
email=user_data.email,
hashed_password=hashlib.sha256(user_data.password.encode()).hexdigest(),
full_name=user_data.full_name,
)
db_users.append(db_user)
try:
db.add_all(db_users) # Add all at once
db.commit()
for user in db_users:
db.refresh(user)
return db_users
except IntegrityError:
db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="One or more users already exist"
)
# app/routers/users.py (continued)
from typing import Optional
from fastapi import Query
@router.get("/", response_model=list[UserResponse])
def get_users(
db: DbSession,
skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(100, ge=1, le=1000, description="Max records to return"),
is_active: Optional[bool] = Query(None, description="Filter by active status"),
search: Optional[str] = Query(None, description="Search by username or email"),
):
"""
Get all users with filtering, search, and pagination.
"""
query = db.query(User)
# Apply filters
if is_active is not None:
query = query.filter(User.is_active == is_active)
if search:
search_pattern = f"%{search}%"
query = query.filter(
(User.username.ilike(search_pattern)) |
(User.email.ilike(search_pattern)) |
(User.full_name.ilike(search_pattern))
)
# Apply pagination
total = query.count()
users = query.order_by(User.created_at.desc()).offset(skip).limit(limit).all()
return users
@router.get("/{user_id}", response_model=UserResponse)
def get_user(user_id: int, db: DbSession):
"""Get a single user by ID."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with id {user_id} not found"
)
return user
@router.get("/by-email/{email}", response_model=UserResponse)
def get_user_by_email(email: str, db: DbSession):
"""Get a user by email address."""
user = db.query(User).filter(User.email == email).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with email {email} not found"
)
return user
# Advanced query examples
@router.get("/stats/active-count")
def get_active_user_count(db: DbSession):
"""Get count of active users."""
from sqlalchemy import func
result = db.query(func.count(User.id)).filter(User.is_active == True).scalar()
return {"active_users": result}
# app/routers/users.py (continued)
from app.schemas.user import UserUpdate
@router.put("/{user_id}", response_model=UserResponse)
def update_user(user_id: int, user_data: UserUpdate, db: DbSession):
"""
Full update of a user (PUT - replaces all fields).
"""
db_user = db.query(User).filter(User.id == user_id).first()
if not db_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with id {user_id} not found"
)
# Update all fields from the request body
update_data = user_data.model_dump(exclude_unset=False)
for field, value in update_data.items():
setattr(db_user, field, value)
try:
db.commit()
db.refresh(db_user)
return db_user
except IntegrityError:
db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Update would violate a unique constraint"
)
@router.patch("/{user_id}", response_model=UserResponse)
def partial_update_user(user_id: int, user_data: UserUpdate, db: DbSession):
"""
Partial update of a user (PATCH - only updates provided fields).
"""
db_user = db.query(User).filter(User.id == user_id).first()
if not db_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with id {user_id} not found"
)
# Only update fields that were explicitly set
update_data = user_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_user, field, value)
try:
db.commit()
db.refresh(db_user)
return db_user
except IntegrityError:
db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Update would violate a unique constraint"
)
# Bulk update example
@router.patch("/bulk/deactivate")
def deactivate_inactive_users(days: int, db: DbSession):
"""Deactivate users who haven't logged in for N days."""
from datetime import datetime, timedelta
cutoff_date = datetime.utcnow() - timedelta(days=days)
updated_count = (
db.query(User)
.filter(User.is_active == True, User.updated_at < cutoff_date)
.update({"is_active": False}, synchronize_session="fetch")
)
db.commit()
return {"deactivated_count": updated_count}
# app/routers/users.py (continued)
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(user_id: int, db: DbSession):
"""
Delete a user by ID.
With cascade="all, delete-orphan" on relationships,
related records (posts, orders) are also deleted.
"""
db_user = db.query(User).filter(User.id == user_id).first()
if not db_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with id {user_id} not found"
)
db.delete(db_user)
db.commit()
return None # 204 No Content
# Soft delete pattern (recommended for production)
@router.delete("/{user_id}/soft")
def soft_delete_user(user_id: int, db: DbSession):
"""
Soft delete - deactivate instead of removing from database.
Data can be recovered if needed.
"""
db_user = db.query(User).filter(User.id == user_id).first()
if not db_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with id {user_id} not found"
)
db_user.is_active = False
db.commit()
return {"message": f"User {user_id} has been deactivated"}
# Bulk delete
@router.delete("/bulk/inactive")
def delete_inactive_users(db: DbSession):
"""Delete all inactive users."""
deleted_count = (
db.query(User)
.filter(User.is_active == False)
.delete(synchronize_session="fetch")
)
db.commit()
return {"deleted_count": deleted_count}
One of the most important patterns in FastAPI development is separating your Pydantic schemas (for API validation and serialization) from your SQLAlchemy models (for database interaction). This separation of concerns keeps your code clean, secure, and maintainable.
| Concern | Pydantic Schema | SQLAlchemy Model |
|---|---|---|
| Purpose | API validation & serialization | Database table representation |
| Passwords | Accepts plain text, never returns it | Stores hashed version only |
| Computed Fields | Can include calculated values | Only stores raw data |
| Relationships | Controls nesting depth | Defines all relationships |
| Validation | Input validation rules | Database constraints |
| Versioning | Can have v1, v2 schemas | Single source of truth |
# app/schemas/user.py
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, EmailStr, Field, ConfigDict
# ─── Base Schema (shared fields) ───────────────────────────────
class UserBase(BaseModel):
"""Fields shared across all user schemas."""
username: str = Field(..., min_length=3, max_length=50, pattern="^[a-zA-Z0-9_]+$")
email: EmailStr
full_name: Optional[str] = Field(None, max_length=100)
# ─── Create Schema (input for creating) ────────────────────────
class UserCreate(UserBase):
"""Schema for creating a new user. Includes password."""
password: str = Field(..., min_length=8, max_length=100)
# Example of custom validation
@classmethod
def validate_password_strength(cls, v):
if not any(c.isupper() for c in v):
raise ValueError("Password must contain at least one uppercase letter")
if not any(c.isdigit() for c in v):
raise ValueError("Password must contain at least one digit")
return v
# ─── Update Schema (input for updating) ────────────────────────
class UserUpdate(BaseModel):
"""Schema for updating a user. All fields optional for PATCH."""
username: Optional[str] = Field(None, min_length=3, max_length=50)
email: Optional[EmailStr] = None
full_name: Optional[str] = Field(None, max_length=100)
bio: Optional[str] = Field(None, max_length=1000)
is_active: Optional[bool] = None
# ─── Response Schema (output) ──────────────────────────────────
class UserResponse(UserBase):
"""
Schema for returning user data in API responses.
Note: hashed_password is NOT included - never expose it!
"""
id: int
is_active: bool
created_at: datetime
updated_at: datetime
# This tells Pydantic to read data from ORM model attributes
# In Pydantic v2, use model_config instead of class Config
model_config = ConfigDict(from_attributes=True)
# ─── Response with Relations ───────────────────────────────────
class UserWithPosts(UserResponse):
"""User response including their posts."""
posts: List["PostResponse"] = []
model_config = ConfigDict(from_attributes=True)
# ─── Minimal Response (for lists) ──────────────────────────────
class UserSummary(BaseModel):
"""Lightweight user representation for list endpoints."""
id: int
username: str
email: str
is_active: bool
model_config = ConfigDict(from_attributes=True)
# app/schemas/product.py
from datetime import datetime
from typing import Optional
from decimal import Decimal
from pydantic import BaseModel, Field, ConfigDict, field_validator
class ProductBase(BaseModel):
"""Shared product fields."""
name: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = None
price: Decimal = Field(..., gt=0, decimal_places=2)
sku: str = Field(..., min_length=1, max_length=50)
category_id: Optional[int] = None
class ProductCreate(ProductBase):
"""Schema for creating a product."""
stock_quantity: int = Field(0, ge=0)
@field_validator("sku")
@classmethod
def validate_sku_format(cls, v: str) -> str:
"""SKU must be uppercase alphanumeric with hyphens."""
if not all(c.isalnum() or c == "-" for c in v):
raise ValueError("SKU must contain only letters, numbers, and hyphens")
return v.upper()
class ProductUpdate(BaseModel):
"""Schema for updating a product. All fields optional."""
name: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
price: Optional[Decimal] = Field(None, gt=0)
stock_quantity: Optional[int] = Field(None, ge=0)
is_active: Optional[bool] = None
category_id: Optional[int] = None
class ProductResponse(ProductBase):
"""Schema for product API response."""
id: int
slug: str
stock_quantity: int
is_active: bool
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class ProductWithCategory(ProductResponse):
"""Product with its category details."""
category: Optional["CategoryResponse"] = None
model_config = ConfigDict(from_attributes=True)
# Understanding from_attributes (Pydantic v2) / orm_mode (Pydantic v1)
# Without from_attributes, Pydantic expects dict-like data:
user_dict = {"id": 1, "username": "john", "email": "john@example.com"}
user_schema = UserResponse(**user_dict) # Works
# With from_attributes=True, Pydantic can read from ORM objects:
db_user = db.query(User).first() # SQLAlchemy model instance
user_schema = UserResponse.model_validate(db_user) # Works!
# This is what FastAPI does automatically when you set response_model:
@router.get("/{user_id}", response_model=UserResponse)
def get_user(user_id: int, db: DbSession):
user = db.query(User).first()
return user # FastAPI converts this using UserResponse.model_validate()
# Pydantic v1 (older) vs v2 (current):
# v1:
class UserResponseV1(BaseModel):
class Config:
orm_mode = True # Old syntax
# v2:
class UserResponseV2(BaseModel):
model_config = ConfigDict(from_attributes=True) # New syntax
# app/schemas/common.py
from typing import Generic, TypeVar, List, Optional
from pydantic import BaseModel
T = TypeVar("T")
class PaginatedResponse(BaseModel, Generic[T]):
"""Generic paginated response wrapper."""
items: List[T]
total: int
page: int
page_size: int
total_pages: int
has_next: bool
has_previous: bool
# Usage in route
@router.get("/", response_model=PaginatedResponse[UserResponse])
def get_users(
db: DbSession,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
):
"""Get paginated list of users."""
query = db.query(User).filter(User.is_active == True)
total = query.count()
total_pages = (total + page_size - 1) // page_size
users = (
query
.order_by(User.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return PaginatedResponse(
items=users,
total=total,
page=page,
page_size=page_size,
total_pages=total_pages,
has_next=page < total_pages,
has_previous=page > 1,
)
Alembic is the migration tool for SQLAlchemy. It tracks changes to your database models and generates migration scripts to apply those changes to your database schema. Think of it as “version control for your database.”
# Install Alembic pip install alembic # Initialize Alembic in your project cd fastapi-database-tutorial alembic init alembic
This creates the following structure:
alembic/ ├── versions/ # Migration scripts go here ├── env.py # Alembic environment configuration ├── README # Alembic README └── script.py.mako # Template for new migrations alembic.ini # Main Alembic configuration file
Update the Alembic configuration to use your SQLAlchemy models and database URL:
# alembic/env.py
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
# Import your models and Base
from app.database import Base
from app.config import get_settings
# Import ALL models so Alembic can detect them
from app.models.user import User
from app.models.post import Post
from app.models.tag import Tag
from app.models.product import Product, Category
from app.models.order import Order, OrderItem
# Alembic Config object
config = context.config
# Set the database URL from our app settings
settings = get_settings()
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
# Interpret the config file for Python logging
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Set target metadata for auto-generation
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
Generates SQL scripts without connecting to the database.
Useful for reviewing changes before applying them.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
# Compare column types for detecting type changes
compare_type=True,
# Compare server defaults
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
Connects to the database and applies changes directly.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
# Auto-generate a migration by comparing models to database alembic revision --autogenerate -m "create users table" # Create an empty migration (for custom SQL) alembic revision -m "add custom index" # View current migration status alembic current # View migration history alembic history --verbose
# alembic/versions/001_create_users_table.py
"""create users table
Revision ID: a1b2c3d4e5f6
Revises:
Create Date: 2024-01-15 10:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Apply migration - create the users table."""
op.create_table(
"users",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("username", sa.String(length=50), nullable=False),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("hashed_password", sa.String(length=255), nullable=False),
sa.Column("full_name", sa.String(length=100), nullable=True),
sa.Column("bio", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("is_superuser", sa.Boolean(), nullable=False, server_default="0"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
# Create indexes
op.create_index("ix_users_username", "users", ["username"], unique=True)
op.create_index("ix_users_email", "users", ["email"], unique=True)
def downgrade() -> None:
"""Reverse migration - drop the users table."""
op.drop_index("ix_users_email", table_name="users")
op.drop_index("ix_users_username", table_name="users")
op.drop_table("users")
# Apply all pending migrations alembic upgrade head # Apply migrations up to a specific revision alembic upgrade a1b2c3d4e5f6 # Apply the next migration only alembic upgrade +1 # Revert the last migration alembic downgrade -1 # Revert to a specific revision alembic downgrade a1b2c3d4e5f6 # Revert all migrations (back to empty database) alembic downgrade base # Generate SQL without executing (for review) alembic upgrade head --sql
# alembic/versions/003_seed_default_categories.py
"""seed default categories
Revision ID: c3d4e5f6g7h8
"""
from alembic import op
import sqlalchemy as sa
from datetime import datetime
revision = "c3d4e5f6g7h8"
down_revision = "b2c3d4e5f6g7"
def upgrade() -> None:
"""Insert default categories."""
categories_table = sa.table(
"categories",
sa.column("id", sa.Integer),
sa.column("name", sa.String),
sa.column("slug", sa.String),
sa.column("description", sa.Text),
)
op.bulk_insert(categories_table, [
{"id": 1, "name": "Electronics", "slug": "electronics",
"description": "Electronic devices and accessories"},
{"id": 2, "name": "Clothing", "slug": "clothing",
"description": "Apparel and fashion items"},
{"id": 3, "name": "Books", "slug": "books",
"description": "Physical and digital books"},
{"id": 4, "name": "Home & Garden", "slug": "home-garden",
"description": "Home improvement and garden supplies"},
])
def downgrade() -> None:
"""Remove default categories."""
op.execute("DELETE FROM categories WHERE id IN (1, 2, 3, 4)")
upgrade() and downgrade()"add email index to users" not "update"create_all() in production — always use Alembic migrationsFastAPI is built on ASGI and supports async/await natively. Using async database access can significantly improve throughput for I/O-bound applications by allowing the server to handle other requests while waiting for database responses.
# Install async drivers pip install sqlalchemy[asyncio] pip install aiosqlite # For SQLite pip install asyncpg # For PostgreSQL (recommended) pip install aiomysql # For MySQL
# app/database_async.py
from sqlalchemy.ext.asyncio import (
create_async_engine,
async_sessionmaker,
AsyncSession,
AsyncEngine,
)
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
settings = get_settings()
# Async connection URLs use different drivers
# SQLite: sqlite+aiosqlite:///./app.db
# PostgreSQL: postgresql+asyncpg://user:pass@localhost:5432/mydb
# MySQL: mysql+aiomysql://user:pass@localhost:3306/mydb
ASYNC_DATABASE_URL = settings.DATABASE_URL.replace(
"sqlite://", "sqlite+aiosqlite://"
).replace(
"postgresql://", "postgresql+asyncpg://"
)
# Create async engine
async_engine: AsyncEngine = create_async_engine(
ASYNC_DATABASE_URL,
echo=settings.DATABASE_ECHO,
pool_size=10,
max_overflow=20,
pool_timeout=30,
pool_recycle=1800,
pool_pre_ping=True,
)
# Create async session factory
AsyncSessionLocal = async_sessionmaker(
bind=async_engine,
class_=AsyncSession,
expire_on_commit=False, # Important for async - prevents lazy loading issues
autocommit=False,
autoflush=False,
)
class Base(DeclarativeBase):
"""Base class for async models (same models work for both sync and async)."""
pass
async def create_tables():
"""Create all tables asynchronously."""
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def drop_tables():
"""Drop all tables asynchronously (for testing)."""
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
# app/dependencies_async.py
from typing import AsyncGenerator, Annotated
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import Depends
from app.database_async import AsyncSessionLocal
async def get_async_db() -> AsyncGenerator[AsyncSession, None]:
"""
Async dependency that provides a database session per request.
"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
# Type alias for cleaner signatures
AsyncDbSession = Annotated[AsyncSession, Depends(get_async_db)]
# app/routers/users_async.py
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select, func, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.dependencies_async import AsyncDbSession
from app.models.user import User
from app.schemas.user import UserCreate, UserResponse, UserWithPosts
import hashlib
router = APIRouter(prefix="/async/users", tags=["users-async"])
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(user_data: UserCreate, db: AsyncDbSession):
"""Create a new user asynchronously."""
# Check for existing user
stmt = select(User).where(
(User.email == user_data.email) | (User.username == user_data.username)
)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User already exists"
)
db_user = User(
username=user_data.username,
email=user_data.email,
hashed_password=hashlib.sha256(user_data.password.encode()).hexdigest(),
full_name=user_data.full_name,
)
db.add(db_user)
await db.flush() # Write to DB but don't commit yet
await db.refresh(db_user) # Get generated values
return db_user
@router.get("/", response_model=list[UserResponse])
async def get_users(
db: AsyncDbSession,
skip: int = 0,
limit: int = 100,
):
"""Get all users asynchronously."""
stmt = (
select(User)
.where(User.is_active == True)
.order_by(User.created_at.desc())
.offset(skip)
.limit(limit)
)
result = await db.execute(stmt)
users = result.scalars().all()
return users
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(user_id: int, db: AsyncDbSession):
"""Get a single user by ID."""
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with id {user_id} not found"
)
return user
@router.get("/{user_id}/with-posts", response_model=UserWithPosts)
async def get_user_with_posts(user_id: int, db: AsyncDbSession):
"""Get a user with their posts (eager loading)."""
stmt = (
select(User)
.options(selectinload(User.posts)) # Eager load posts
.where(User.id == user_id)
)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with id {user_id} not found"
)
return user
@router.put("/{user_id}", response_model=UserResponse)
async def update_user(user_id: int, user_data: dict, db: AsyncDbSession):
"""Update a user asynchronously."""
stmt = (
update(User)
.where(User.id == user_id)
.values(**user_data)
.returning(User) # PostgreSQL only - returns updated row
)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with id {user_id} not found"
)
await db.refresh(user)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(user_id: int, db: AsyncDbSession):
"""Delete a user asynchronously."""
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with id {user_id} not found"
)
await db.delete(user)
| Scenario | Sync or Async? | Reason |
|---|---|---|
| High-traffic API | Async | Better concurrency under load |
| Simple CRUD app | Either | Sync is simpler, async adds complexity |
| Multiple DB calls per request | Async | Can run queries concurrently |
| CPU-heavy processing | Sync | Async does not help with CPU-bound work |
| External API calls + DB | Async | Can await both without blocking |
| Small team / prototype | Sync | Less complexity, faster development |
| Microservices at scale | Async | Better resource utilization |
async def, all database operations in that route should use async sessions. If your route is plain def, use sync sessions. FastAPI runs sync routes in a thread pool, so they do not block the event loop.Connection pooling is critical for production applications. Creating a new database connection for every request is expensive — a pool maintains a set of reusable connections that are shared across requests.
""" Connection Pool Lifecycle: 1. Application starts → Pool creates `pool_size` connections 2. Request arrives → Pool provides an available connection 3. Request completes → Connection is returned to pool (not closed) 4. Pool is full → New requests wait up to `pool_timeout` seconds 5. Pool overflow → Up to `max_overflow` temporary connections created 6. Connection stale → `pool_pre_ping` detects and replaces it 7. Connection old → `pool_recycle` replaces connections after N seconds Pool States: ┌─────────────────────────────────────────┐ │ Connection Pool │ │ │ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │ │ Conn │ │ Conn │ │ Conn │ │ Conn │ │ ← pool_size=5 │ │ #1 │ │ #2 │ │ #3 │ │ #4 │ │ │ │(idle)│ │(busy)│ │(idle)│ │(busy)│ │ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │ │ │ ┌──────┐ │ │ │ Conn │ ← max_overflow connections │ │ │ #5 │ (temporary, closed when │ │ │(busy)│ returned to pool) │ │ └──────┘ │ └─────────────────────────────────────────┘ """
# app/database.py - Production-ready pool configuration
from sqlalchemy import create_engine
from sqlalchemy.pool import QueuePool, NullPool, StaticPool
# ─── Standard Configuration (most applications) ────────────────
engine = create_engine(
"postgresql://user:pass@localhost:5432/mydb",
# Pool implementation (QueuePool is default for non-SQLite)
poolclass=QueuePool,
# Number of persistent connections in the pool
# Rule of thumb: start with 5-10, increase based on load testing
pool_size=10,
# Additional connections allowed when pool is exhausted
# These connections are closed (not returned to pool) when done
max_overflow=20,
# Seconds to wait for a connection before raising TimeoutError
pool_timeout=30,
# Recycle connections after this many seconds
# Prevents issues with databases that close idle connections
# MySQL default wait_timeout is 28800 (8 hours)
pool_recycle=1800, # 30 minutes
# Test connections before using them
# Issues a SELECT 1 before handing out a connection
pool_pre_ping=True,
# Log pool events for debugging
echo_pool=True, # Set to False in production
)
# ─── Testing Configuration (SQLite in-memory) ──────────────────
test_engine = create_engine(
"sqlite:///:memory:",
poolclass=StaticPool, # Single connection for all threads
connect_args={"check_same_thread": False},
)
# ─── Serverless Configuration (no persistent pool) ─────────────
serverless_engine = create_engine(
"postgresql://user:pass@host:5432/mydb",
poolclass=NullPool, # No pooling - new connection every time
# Useful for AWS Lambda, Google Cloud Functions, etc.
)
# app/routers/health.py
from fastapi import APIRouter
from app.database import engine
router = APIRouter(tags=["health"])
@router.get("/health/db")
def database_health():
"""Check database connection pool health."""
pool = engine.pool
return {
"pool_size": pool.size(),
"checked_in": pool.checkedin(), # Available connections
"checked_out": pool.checkedout(), # In-use connections
"overflow": pool.overflow(), # Overflow connections in use
"total_connections": pool.checkedin() + pool.checkedout(),
"status": "healthy" if pool.checkedin() > 0 or pool.size() > pool.checkedout() else "stressed",
}
# Event listeners for monitoring
from sqlalchemy import event
@event.listens_for(engine, "checkout")
def on_checkout(dbapi_conn, connection_rec, connection_proxy):
"""Called when a connection is checked out from the pool."""
logger.debug(f"Connection checked out. Pool: {engine.pool.status()}")
@event.listens_for(engine, "checkin")
def on_checkin(dbapi_conn, connection_rec):
"""Called when a connection is returned to the pool."""
logger.debug(f"Connection checked in. Pool: {engine.pool.status()}")
@event.listens_for(engine, "connect")
def on_connect(dbapi_conn, connection_rec):
"""Called when a new raw connection is created."""
logger.info("New database connection created")
@event.listens_for(engine, "invalidate")
def on_invalidate(dbapi_conn, connection_rec, exception):
"""Called when a connection is invalidated."""
logger.warning(f"Connection invalidated: {exception}")
| Application Type | pool_size | max_overflow | Notes |
|---|---|---|---|
| Small API (dev) | 5 | 10 | Default settings are fine |
| Medium API | 10-20 | 20-30 | Monitor and adjust |
| High-traffic API | 20-50 | 50-100 | Use connection pooler (PgBouncer) |
| Serverless | N/A | N/A | Use NullPool or external pooler |
| Background workers | 2-5 | 5 | Fewer connections needed |
NullPool in SQLAlchemy and let PgBouncer handle all pooling.Efficiently loading related data is one of the most important skills when working with an ORM. SQLAlchemy provides several strategies for loading relationships, each with different performance characteristics.
The N+1 problem is the most common performance issue with ORMs. It occurs when you load a list of N records and then access a relationship on each one, causing N additional queries.
# THE N+1 PROBLEM - DO NOT DO THIS IN PRODUCTION
# Query 1: Get all users
users = db.query(User).all() # SELECT * FROM users → returns 100 users
# N queries: Access posts for each user (lazy loading)
for user in users:
print(f"{user.username} has {len(user.posts)} posts")
# Each access to user.posts triggers:
# SELECT * FROM posts WHERE author_id = 1
# SELECT * FROM posts WHERE author_id = 2
# SELECT * FROM posts WHERE author_id = 3
# ... 100 more queries!
# Total: 1 + 100 = 101 queries for 100 users!
joinedload uses a SQL JOIN to load related data in a single query. Best for one-to-one and many-to-one relationships.
from sqlalchemy.orm import joinedload
# Single query with JOIN - loads users and posts together
users = (
db.query(User)
.options(joinedload(User.posts))
.all()
)
# Generated SQL:
# SELECT users.*, posts.*
# FROM users
# LEFT OUTER JOIN posts ON users.id = posts.author_id
# Now accessing posts does NOT trigger additional queries
for user in users:
print(f"{user.username} has {len(user.posts)} posts") # No extra query!
# Total: 1 query!
selectinload uses a separate SELECT with an IN clause. Best for one-to-many and many-to-many relationships where JOIN would create duplicate rows.
from sqlalchemy.orm import selectinload
# Two queries: one for users, one for all their posts
users = (
db.query(User)
.options(selectinload(User.posts))
.all()
)
# Generated SQL:
# Query 1: SELECT * FROM users
# Query 2: SELECT * FROM posts WHERE posts.author_id IN (1, 2, 3, ..., 100)
# Total: 2 queries regardless of how many users!
# Nested eager loading
users = (
db.query(User)
.options(
selectinload(User.posts).selectinload(Post.tags),
selectinload(User.orders).selectinload(Order.items),
)
.all()
)
# Loads: users → posts → tags, users → orders → items
# Total: 5 queries (one per table)
| Strategy | SQL Queries | Best For | Trade-offs |
|---|---|---|---|
lazy="select" (default) |
N+1 | Rarely accessed relationships | Simple but can be very slow |
joinedload |
1 (JOIN) | Many-to-one, one-to-one | Can create large result sets with duplicates |
selectinload |
2 (SELECT + IN) | One-to-many, many-to-many | Best general-purpose eager loading |
subqueryload |
2 (SELECT + subquery) | Large collections | Similar to selectinload, uses subquery |
raiseload |
0 (raises error) | Preventing accidental lazy loads | Forces explicit loading strategy |
# app/routers/products.py
from sqlalchemy.orm import joinedload, selectinload
from sqlalchemy import select, func, and_, or_
@router.get("/products/with-categories", response_model=list[ProductWithCategory])
def get_products_with_categories(db: DbSession):
"""Get products with their category (many-to-one → joinedload)."""
products = (
db.query(Product)
.options(joinedload(Product.category))
.filter(Product.is_active == True)
.all()
)
return products
@router.get("/categories/{category_id}/products")
def get_category_with_products(category_id: int, db: DbSession):
"""Get a category with all its products (one-to-many → selectinload)."""
category = (
db.query(Category)
.options(selectinload(Category.products))
.filter(Category.id == category_id)
.first()
)
if not category:
raise HTTPException(status_code=404, detail="Category not found")
return {
"category": category.name,
"product_count": len(category.products),
"products": category.products,
}
@router.get("/orders/{order_id}/details")
def get_order_details(order_id: int, db: DbSession):
"""Get order with items and product details (nested eager loading)."""
order = (
db.query(Order)
.options(
joinedload(Order.user), # many-to-one: joinedload
selectinload(Order.items).joinedload(OrderItem.product), # nested
)
.filter(Order.id == order_id)
.first()
)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
return order
# Explicit JOIN queries (for complex filters and aggregations)
@router.get("/products/by-category-stats")
def get_products_by_category_stats(db: DbSession):
"""Get product count and average price per category."""
results = (
db.query(
Category.name,
func.count(Product.id).label("product_count"),
func.avg(Product.price).label("avg_price"),
func.min(Product.price).label("min_price"),
func.max(Product.price).label("max_price"),
)
.join(Product, Category.id == Product.category_id)
.filter(Product.is_active == True)
.group_by(Category.name)
.order_by(func.count(Product.id).desc())
.all()
)
return [
{
"category": r.name,
"product_count": r.product_count,
"avg_price": float(r.avg_price or 0),
"min_price": float(r.min_price or 0),
"max_price": float(r.max_price or 0),
}
for r in results
]
@router.get("/users/top-spenders")
def get_top_spenders(db: DbSession, limit: int = 10):
"""Get users who have spent the most (multi-table join with aggregation)."""
results = (
db.query(
User.username,
User.email,
func.count(Order.id).label("order_count"),
func.sum(Order.total_amount).label("total_spent"),
)
.join(Order, User.id == Order.user_id)
.filter(Order.status != "cancelled")
.group_by(User.id, User.username, User.email)
.order_by(func.sum(Order.total_amount).desc())
.limit(limit)
.all()
)
return [
{
"username": r.username,
"email": r.email,
"order_count": r.order_count,
"total_spent": float(r.total_spent or 0),
}
for r in results
]
from sqlalchemy.orm import raiseload
# Strict mode: raise an error if any relationship is lazy-loaded
users = (
db.query(User)
.options(
selectinload(User.posts), # This will be loaded
raiseload(User.orders), # This will raise if accessed
raiseload("*"), # ALL other relationships will raise
)
.all()
)
# Accessing user.posts works fine (it was eagerly loaded)
for user in users:
print(user.posts) # OK
# Accessing user.orders raises an error instead of silently
# executing a query. This helps catch N+1 issues during development.
# print(user.orders) # Raises: sqlalchemy.exc.InvalidRequestError
Transactions ensure that a group of database operations either all succeed or all fail. Proper transaction management is essential for data integrity, especially when multiple related changes need to be atomic.
# SQLAlchemy sessions have implicit transaction management
@router.post("/orders/", response_model=OrderResponse)
def create_order(order_data: OrderCreate, db: DbSession):
"""
Create an order with items - all or nothing.
If any step fails, the entire operation is rolled back.
"""
# Step 1: Create the order
order = Order(
order_number=generate_order_number(),
user_id=order_data.user_id,
status=OrderStatus.PENDING,
)
db.add(order)
db.flush() # Get the order.id without committing
# Step 2: Create order items and calculate total
total = 0
for item_data in order_data.items:
# Verify product exists and has stock
product = db.query(Product).filter(Product.id == item_data.product_id).first()
if not product:
raise HTTPException(status_code=404, detail=f"Product {item_data.product_id} not found")
if product.stock_quantity < item_data.quantity:
raise HTTPException(
status_code=400,
detail=f"Insufficient stock for {product.name}"
)
# Create order item
item_total = product.price * item_data.quantity
order_item = OrderItem(
order_id=order.id,
product_id=product.id,
quantity=item_data.quantity,
unit_price=product.price,
total_price=item_total,
)
db.add(order_item)
total += item_total
# Step 3: Reduce stock
product.stock_quantity -= item_data.quantity
# Step 4: Update order total
order.total_amount = total
# Step 5: Commit everything at once
# If ANY step above fails, nothing is committed
db.commit()
db.refresh(order)
return order
from sqlalchemy.orm import Session
def transfer_funds(
db: Session,
from_account_id: int,
to_account_id: int,
amount: float,
) -> dict:
"""
Transfer funds between accounts with explicit transaction control.
"""
try:
# Begin a transaction (implicit with session)
from_account = db.query(Account).filter(Account.id == from_account_id).with_for_update().first()
to_account = db.query(Account).filter(Account.id == to_account_id).with_for_update().first()
if not from_account or not to_account:
raise ValueError("Account not found")
if from_account.balance < amount:
raise ValueError("Insufficient funds")
# Perform the transfer
from_account.balance -= amount
to_account.balance += amount
# Record the transaction
transaction = Transaction(
from_account_id=from_account_id,
to_account_id=to_account_id,
amount=amount,
status="completed",
)
db.add(transaction)
db.commit()
return {"status": "success", "transaction_id": transaction.id}
except Exception as e:
db.rollback()
# Log the error
logger.error(f"Transfer failed: {e}")
raise
@router.post("/orders/with-notifications")
def create_order_with_notification(order_data: OrderCreate, db: DbSession):
"""
Create an order and try to send a notification.
The order should be saved even if the notification fails.
"""
# Create the order (main transaction)
order = Order(
order_number=generate_order_number(),
user_id=order_data.user_id,
status=OrderStatus.PENDING,
total_amount=order_data.total,
)
db.add(order)
db.flush()
# Try to create a notification (nested transaction / savepoint)
try:
nested = db.begin_nested() # Creates a SAVEPOINT
notification = Notification(
user_id=order_data.user_id,
message=f"Order {order.order_number} confirmed!",
order_id=order.id,
)
db.add(notification)
nested.commit() # Release the savepoint
except Exception as e:
# Notification failed, but order is still intact
# The savepoint rollback only affects the notification
logger.warning(f"Failed to create notification: {e}")
# nested is already rolled back, main transaction continues
# Commit the main transaction (order is saved regardless)
db.commit()
db.refresh(order)
return order
# Pattern 1: Service layer with explicit transactions
class OrderService:
def __init__(self, db: Session):
self.db = db
def create_order(self, order_data: OrderCreate) -> Order:
"""Business logic with transaction management."""
try:
order = self._build_order(order_data)
self._validate_stock(order_data.items)
self._create_order_items(order, order_data.items)
self._update_stock(order_data.items)
self._calculate_total(order)
self.db.commit()
self.db.refresh(order)
return order
except Exception:
self.db.rollback()
raise
def _build_order(self, data: OrderCreate) -> Order:
order = Order(order_number=generate_order_number(), user_id=data.user_id)
self.db.add(order)
self.db.flush()
return order
# Pattern 2: Context manager for transactions
from contextlib import contextmanager
@contextmanager
def transaction(db: Session):
"""Context manager for explicit transaction boundaries."""
try:
yield db
db.commit()
except Exception:
db.rollback()
raise
# Usage:
def process_payment(db: Session, order_id: int, payment_data: dict):
with transaction(db):
order = db.query(Order).get(order_id)
order.status = OrderStatus.CONFIRMED
payment = Payment(order_id=order_id, **payment_data)
db.add(payment)
# Commits automatically on exit, rolls back on exception
with_for_update() for rows that need pessimistic locking (e.g., account balances)begin_nested()) when partial failures are acceptableIntegrityError for unique constraint violationsdb.flush() to get generated IDs without committing the transactionThe Repository pattern abstracts data access logic behind a clean interface. Instead of scattering SQLAlchemy queries throughout your route handlers, you centralize them in repository classes. This makes your code more testable, reusable, and easier to maintain.
| Without Repository | With Repository |
|---|---|
| Queries scattered in route handlers | Queries centralized in one place |
| Hard to test (need full FastAPI context) | Easy to test (mock the repository) |
| Duplicate query logic across routes | Reusable query methods |
| Routes know about SQLAlchemy internals | Routes only know the repository interface |
| Switching ORMs requires changing every route | Switching ORMs only changes repositories |
# app/repositories/base.py
from typing import Generic, TypeVar, Type, Optional, List, Any
from sqlalchemy.orm import Session
from sqlalchemy import select, func
from app.database import Base
# TypeVar for the model type
ModelType = TypeVar("ModelType", bound=Base)
class BaseRepository(Generic[ModelType]):
"""
Generic repository with common CRUD operations.
Subclass this for model-specific repositories.
"""
def __init__(self, model: Type[ModelType], db: Session):
self.model = model
self.db = db
def get(self, id: int) -> Optional[ModelType]:
"""Get a single record by ID."""
return self.db.query(self.model).filter(self.model.id == id).first()
def get_or_404(self, id: int) -> ModelType:
"""Get a single record by ID or raise HTTPException."""
from fastapi import HTTPException, status
obj = self.get(id)
if not obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"{self.model.__name__} with id {id} not found",
)
return obj
def get_all(
self,
skip: int = 0,
limit: int = 100,
**filters: Any,
) -> List[ModelType]:
"""Get all records with optional filtering and pagination."""
query = self.db.query(self.model)
# Apply dynamic filters
for field, value in filters.items():
if value is not None and hasattr(self.model, field):
query = query.filter(getattr(self.model, field) == value)
return query.offset(skip).limit(limit).all()
def count(self, **filters: Any) -> int:
"""Count records with optional filtering."""
query = self.db.query(func.count(self.model.id))
for field, value in filters.items():
if value is not None and hasattr(self.model, field):
query = query.filter(getattr(self.model, field) == value)
return query.scalar()
def create(self, obj_data: dict) -> ModelType:
"""Create a new record."""
db_obj = self.model(**obj_data)
self.db.add(db_obj)
self.db.flush()
self.db.refresh(db_obj)
return db_obj
def update(self, id: int, obj_data: dict) -> Optional[ModelType]:
"""Update an existing record."""
db_obj = self.get(id)
if not db_obj:
return None
for field, value in obj_data.items():
if value is not None:
setattr(db_obj, field, value)
self.db.flush()
self.db.refresh(db_obj)
return db_obj
def delete(self, id: int) -> bool:
"""Delete a record by ID. Returns True if deleted."""
db_obj = self.get(id)
if not db_obj:
return False
self.db.delete(db_obj)
self.db.flush()
return True
def exists(self, **filters: Any) -> bool:
"""Check if a record exists with given filters."""
query = self.db.query(self.model.id)
for field, value in filters.items():
if hasattr(self.model, field):
query = query.filter(getattr(self.model, field) == value)
return query.first() is not None
# app/repositories/user.py
from typing import Optional, List
from sqlalchemy.orm import Session, selectinload
from sqlalchemy import or_
from app.repositories.base import BaseRepository
from app.models.user import User
import hashlib
class UserRepository(BaseRepository[User]):
"""Repository for User-specific database operations."""
def __init__(self, db: Session):
super().__init__(User, db)
def get_by_email(self, email: str) -> Optional[User]:
"""Find a user by email address."""
return self.db.query(User).filter(User.email == email).first()
def get_by_username(self, username: str) -> Optional[User]:
"""Find a user by username."""
return self.db.query(User).filter(User.username == username).first()
def search(self, query: str, skip: int = 0, limit: int = 20) -> List[User]:
"""Search users by username, email, or full name."""
pattern = f"%{query}%"
return (
self.db.query(User)
.filter(
or_(
User.username.ilike(pattern),
User.email.ilike(pattern),
User.full_name.ilike(pattern),
)
)
.offset(skip)
.limit(limit)
.all()
)
def get_active_users(self, skip: int = 0, limit: int = 100) -> List[User]:
"""Get only active users."""
return (
self.db.query(User)
.filter(User.is_active == True)
.order_by(User.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
def get_with_posts(self, user_id: int) -> Optional[User]:
"""Get a user with their posts eagerly loaded."""
return (
self.db.query(User)
.options(selectinload(User.posts))
.filter(User.id == user_id)
.first()
)
def create_user(self, username: str, email: str, password: str, **kwargs) -> User:
"""Create a new user with password hashing."""
user_data = {
"username": username,
"email": email,
"hashed_password": hashlib.sha256(password.encode()).hexdigest(),
**kwargs,
}
return self.create(user_data)
def deactivate(self, user_id: int) -> Optional[User]:
"""Soft-delete a user by deactivating their account."""
return self.update(user_id, {"is_active": False})
def email_exists(self, email: str) -> bool:
"""Check if an email is already registered."""
return self.exists(email=email)
# app/repositories/product.py
from typing import Optional, List
from decimal import Decimal
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_
from app.repositories.base import BaseRepository
from app.models.product import Product
class ProductRepository(BaseRepository[Product]):
"""Repository for Product-specific database operations."""
def __init__(self, db: Session):
super().__init__(Product, db)
def get_by_sku(self, sku: str) -> Optional[Product]:
"""Find a product by SKU."""
return self.db.query(Product).filter(Product.sku == sku).first()
def get_by_slug(self, slug: str) -> Optional[Product]:
"""Find a product by URL slug."""
return self.db.query(Product).filter(Product.slug == slug).first()
def get_by_category(
self, category_id: int, skip: int = 0, limit: int = 50
) -> List[Product]:
"""Get all products in a category."""
return (
self.db.query(Product)
.filter(
and_(
Product.category_id == category_id,
Product.is_active == True,
)
)
.order_by(Product.name)
.offset(skip)
.limit(limit)
.all()
)
def get_in_price_range(
self, min_price: Decimal, max_price: Decimal
) -> List[Product]:
"""Get products within a price range."""
return (
self.db.query(Product)
.filter(
and_(
Product.price >= min_price,
Product.price <= max_price,
Product.is_active == True,
)
)
.order_by(Product.price)
.all()
)
def get_low_stock(self, threshold: int = 10) -> List[Product]:
"""Get products with stock below threshold."""
return (
self.db.query(Product)
.filter(
and_(
Product.stock_quantity <= threshold,
Product.is_active == True,
)
)
.order_by(Product.stock_quantity)
.all()
)
def update_stock(self, product_id: int, quantity_change: int) -> Optional[Product]:
"""Update product stock quantity (positive to add, negative to subtract)."""
product = self.get(product_id)
if not product:
return None
new_quantity = product.stock_quantity + quantity_change
if new_quantity < 0:
raise ValueError(f"Insufficient stock. Current: {product.stock_quantity}")
product.stock_quantity = new_quantity
self.db.flush()
self.db.refresh(product)
return product
def get_with_category(self, product_id: int) -> Optional[Product]:
"""Get a product with its category eagerly loaded."""
return (
self.db.query(Product)
.options(joinedload(Product.category))
.filter(Product.id == product_id)
.first()
)
# app/dependencies.py
from typing import Annotated
from fastapi import Depends
from sqlalchemy.orm import Session
from app.database import SessionLocal
from app.repositories.user import UserRepository
from app.repositories.product import ProductRepository
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
DbSession = Annotated[Session, Depends(get_db)]
# Repository dependencies
def get_user_repo(db: DbSession) -> UserRepository:
return UserRepository(db)
def get_product_repo(db: DbSession) -> ProductRepository:
return ProductRepository(db)
# Type aliases for clean route signatures
UserRepo = Annotated[UserRepository, Depends(get_user_repo)]
ProductRepo = Annotated[ProductRepository, Depends(get_product_repo)]
# app/routers/users.py
from fastapi import APIRouter, HTTPException, status
from app.dependencies import UserRepo, DbSession
from app.schemas.user import UserCreate, UserResponse, UserUpdate
router = APIRouter(prefix="/users", tags=["users"])
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(user_data: UserCreate, repo: UserRepo, db: DbSession):
"""Create a new user using the repository."""
# Check for duplicates
if repo.email_exists(user_data.email):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email already registered"
)
user = repo.create_user(
username=user_data.username,
email=user_data.email,
password=user_data.password,
full_name=user_data.full_name,
)
db.commit()
return user
@router.get("/", response_model=list[UserResponse])
def get_users(repo: UserRepo, skip: int = 0, limit: int = 100):
"""Get all active users."""
return repo.get_active_users(skip=skip, limit=limit)
@router.get("/search", response_model=list[UserResponse])
def search_users(q: str, repo: UserRepo):
"""Search users by name, email, or username."""
return repo.search(q)
@router.get("/{user_id}", response_model=UserResponse)
def get_user(user_id: int, repo: UserRepo):
"""Get a user by ID (raises 404 if not found)."""
return repo.get_or_404(user_id)
@router.patch("/{user_id}", response_model=UserResponse)
def update_user(user_id: int, user_data: UserUpdate, repo: UserRepo, db: DbSession):
"""Update a user."""
user = repo.update(user_id, user_data.model_dump(exclude_unset=True))
if not user:
raise HTTPException(status_code=404, detail="User not found")
db.commit()
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(user_id: int, repo: UserRepo, db: DbSession):
"""Soft-delete a user."""
user = repo.deactivate(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
db.commit()
Now let us bring everything together into a complete, production-ready e-commerce data layer. This project includes Products, Categories, Users, Orders, and full CRUD operations with relationships and proper architecture.
ecommerce/ ├── app/ │ ├── __init__.py │ ├── main.py │ ├── config.py │ ├── database.py │ ├── models/ │ │ ├── __init__.py # Import all models │ │ ├── user.py │ │ ├── product.py │ │ ├── category.py │ │ └── order.py │ ├── schemas/ │ │ ├── __init__.py │ │ ├── user.py │ │ ├── product.py │ │ ├── category.py │ │ └── order.py │ ├── repositories/ │ │ ├── __init__.py │ │ ├── base.py │ │ ├── user.py │ │ ├── product.py │ │ └── order.py │ ├── routers/ │ │ ├── __init__.py │ │ ├── users.py │ │ ├── products.py │ │ ├── categories.py │ │ └── orders.py │ └── dependencies.py ├── alembic/ │ ├── versions/ │ └── env.py ├── alembic.ini ├── requirements.txt └── .env
# app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.database import create_tables, engine
from app.routers import users, products, categories, orders
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application startup and shutdown events."""
# Startup: create tables (use Alembic in production)
create_tables()
print("Database tables created successfully")
yield
# Shutdown: dispose engine connections
engine.dispose()
print("Database connections closed")
app = FastAPI(
title="E-Commerce API",
description="Complete e-commerce data layer with FastAPI and SQLAlchemy",
version="1.0.0",
lifespan=lifespan,
)
# Include routers
app.include_router(users.router)
app.include_router(products.router)
app.include_router(categories.router)
app.include_router(orders.router)
@app.get("/")
def root():
return {
"message": "E-Commerce API",
"docs": "/docs",
"version": "1.0.0",
}
@app.get("/health")
def health_check():
"""Health check endpoint."""
from app.database import engine
pool = engine.pool
return {
"status": "healthy",
"database": {
"pool_size": pool.size(),
"connections_in_use": pool.checkedout(),
"connections_available": pool.checkedin(),
},
}
# app/models/__init__.py """ Import all models here so Alembic can detect them for migrations. """ from app.models.user import User from app.models.category import Category from app.models.product import Product from app.models.order import Order, OrderItem __all__ = ["User", "Category", "Product", "Order", "OrderItem"]
# app/models/category.py (complete)
from datetime import datetime
from typing import Optional, List
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
parent_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
# Self-referential relationships
parent: Mapped[Optional["Category"]] = relationship(
"Category", remote_side="Category.id", back_populates="children"
)
children: Mapped[List["Category"]] = relationship(
"Category", back_populates="parent"
)
products: Mapped[List["Product"]] = relationship(
"Product", back_populates="category"
)
def __repr__(self) -> str:
return f"<Category(id={self.id}, name='{self.name}')>"
# app/schemas/order.py
from datetime import datetime
from typing import List, Optional
from decimal import Decimal
from pydantic import BaseModel, Field, ConfigDict
class OrderItemCreate(BaseModel):
"""Schema for adding an item to an order."""
product_id: int
quantity: int = Field(..., gt=0)
class OrderItemResponse(BaseModel):
"""Schema for order item in responses."""
id: int
product_id: int
quantity: int
unit_price: Decimal
total_price: Decimal
model_config = ConfigDict(from_attributes=True)
class OrderCreate(BaseModel):
"""Schema for creating a new order."""
user_id: int
items: List[OrderItemCreate] = Field(..., min_length=1)
shipping_address: Optional[str] = None
class OrderResponse(BaseModel):
"""Schema for order API responses."""
id: int
order_number: str
status: str
total_amount: Decimal
user_id: int
created_at: datetime
items: List[OrderItemResponse] = []
model_config = ConfigDict(from_attributes=True)
class OrderSummary(BaseModel):
"""Lightweight order for list endpoints."""
id: int
order_number: str
status: str
total_amount: Decimal
created_at: datetime
model_config = ConfigDict(from_attributes=True)
# app/repositories/order.py
from typing import Optional, List
from sqlalchemy.orm import Session, selectinload, joinedload
from sqlalchemy import func
from app.repositories.base import BaseRepository
from app.models.order import Order, OrderItem, OrderStatus
from app.models.product import Product
import uuid
from datetime import datetime
class OrderRepository(BaseRepository[Order]):
"""Repository for order operations."""
def __init__(self, db: Session):
super().__init__(Order, db)
def generate_order_number(self) -> str:
"""Generate a unique order number."""
timestamp = datetime.utcnow().strftime("%Y%m%d%H%M%S")
unique_id = uuid.uuid4().hex[:6].upper()
return f"ORD-{timestamp}-{unique_id}"
def get_with_items(self, order_id: int) -> Optional[Order]:
"""Get an order with its items and product details."""
return (
self.db.query(Order)
.options(
selectinload(Order.items).joinedload(OrderItem.product),
joinedload(Order.user),
)
.filter(Order.id == order_id)
.first()
)
def get_user_orders(
self, user_id: int, skip: int = 0, limit: int = 20
) -> List[Order]:
"""Get all orders for a specific user."""
return (
self.db.query(Order)
.filter(Order.user_id == user_id)
.order_by(Order.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
def create_order(self, user_id: int, items: list) -> Order:
"""
Create a complete order with items.
Validates stock, creates order items, updates stock quantities,
and calculates the total — all in a single transaction.
"""
# Create the order
order = Order(
order_number=self.generate_order_number(),
user_id=user_id,
status=OrderStatus.PENDING,
)
self.db.add(order)
self.db.flush() # Get order.id
total_amount = 0
for item_data in items:
# Fetch and validate product
product = (
self.db.query(Product)
.filter(Product.id == item_data.product_id)
.with_for_update() # Lock row to prevent race conditions
.first()
)
if not product:
raise ValueError(f"Product {item_data.product_id} not found")
if not product.is_active:
raise ValueError(f"Product '{product.name}' is not available")
if product.stock_quantity < item_data.quantity:
raise ValueError(
f"Insufficient stock for '{product.name}'. "
f"Available: {product.stock_quantity}, Requested: {item_data.quantity}"
)
# Calculate price
item_total = float(product.price) * item_data.quantity
# Create order item
order_item = OrderItem(
order_id=order.id,
product_id=product.id,
quantity=item_data.quantity,
unit_price=product.price,
total_price=item_total,
)
self.db.add(order_item)
# Update stock
product.stock_quantity -= item_data.quantity
total_amount += item_total
# Set order total
order.total_amount = total_amount
self.db.flush()
self.db.refresh(order)
return order
def update_status(self, order_id: int, new_status: OrderStatus) -> Optional[Order]:
"""Update order status with validation."""
order = self.get(order_id)
if not order:
return None
# Validate status transitions
valid_transitions = {
OrderStatus.PENDING: [OrderStatus.CONFIRMED, OrderStatus.CANCELLED],
OrderStatus.CONFIRMED: [OrderStatus.SHIPPED, OrderStatus.CANCELLED],
OrderStatus.SHIPPED: [OrderStatus.DELIVERED],
OrderStatus.DELIVERED: [],
OrderStatus.CANCELLED: [],
}
if new_status not in valid_transitions.get(order.status, []):
raise ValueError(
f"Cannot transition from {order.status} to {new_status}"
)
order.status = new_status
# If cancelled, restore stock
if new_status == OrderStatus.CANCELLED:
self._restore_stock(order)
self.db.flush()
self.db.refresh(order)
return order
def _restore_stock(self, order: Order):
"""Restore stock quantities when an order is cancelled."""
items = (
self.db.query(OrderItem)
.filter(OrderItem.order_id == order.id)
.all()
)
for item in items:
product = self.db.query(Product).get(item.product_id)
if product:
product.stock_quantity += item.quantity
def get_order_stats(self, user_id: Optional[int] = None) -> dict:
"""Get order statistics."""
query = self.db.query(Order)
if user_id:
query = query.filter(Order.user_id == user_id)
total_orders = query.count()
total_revenue = (
query.filter(Order.status != OrderStatus.CANCELLED)
.with_entities(func.sum(Order.total_amount))
.scalar()
) or 0
status_counts = (
query.with_entities(Order.status, func.count(Order.id))
.group_by(Order.status)
.all()
)
return {
"total_orders": total_orders,
"total_revenue": float(total_revenue),
"orders_by_status": {s.value: c for s, c in status_counts},
}
# app/routers/orders.py
from fastapi import APIRouter, HTTPException, status, Depends
from typing import Annotated
from app.dependencies import DbSession
from app.repositories.order import OrderRepository
from app.schemas.order import OrderCreate, OrderResponse, OrderSummary
from app.models.order import OrderStatus
router = APIRouter(prefix="/orders", tags=["orders"])
def get_order_repo(db: DbSession) -> OrderRepository:
return OrderRepository(db)
OrderRepo = Annotated[OrderRepository, Depends(get_order_repo)]
@router.post("/", response_model=OrderResponse, status_code=status.HTTP_201_CREATED)
def create_order(order_data: OrderCreate, repo: OrderRepo, db: DbSession):
"""Create a new order with items."""
try:
order = repo.create_order(
user_id=order_data.user_id,
items=order_data.items,
)
db.commit()
# Reload with relationships for response
order = repo.get_with_items(order.id)
return order
except ValueError as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
@router.get("/{order_id}", response_model=OrderResponse)
def get_order(order_id: int, repo: OrderRepo):
"""Get order details with items."""
order = repo.get_with_items(order_id)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
return order
@router.get("/user/{user_id}", response_model=list[OrderSummary])
def get_user_orders(user_id: int, repo: OrderRepo, skip: int = 0, limit: int = 20):
"""Get all orders for a user."""
return repo.get_user_orders(user_id, skip=skip, limit=limit)
@router.patch("/{order_id}/status")
def update_order_status(
order_id: int,
new_status: OrderStatus,
repo: OrderRepo,
db: DbSession,
):
"""Update an order's status."""
try:
order = repo.update_status(order_id, new_status)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
db.commit()
return {"order_id": order.id, "new_status": order.status}
except ValueError as e:
db.rollback()
raise HTTPException(status_code=400, detail=str(e))
@router.get("/stats/summary")
def get_order_stats(repo: OrderRepo):
"""Get order statistics."""
return repo.get_order_stats()
# Create requirements.txt cat > requirements.txt << EOF fastapi==0.109.0 uvicorn==0.27.0 sqlalchemy==2.0.25 alembic==1.13.1 pydantic==2.5.3 pydantic-settings==2.1.0 python-dotenv==1.0.0 email-validator==2.1.0 EOF # Install dependencies pip install -r requirements.txt # Create .env file cat > .env << EOF DATABASE_URL=sqlite:///./ecommerce.db DATABASE_ECHO=true DEBUG=true EOF # Initialize Alembic alembic init alembic # Generate initial migration alembic revision --autogenerate -m "initial schema" # Apply migration alembic upgrade head # Run the application uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# Create a category
curl -X POST http://localhost:8000/categories/ \
-H "Content-Type: application/json" \
-d '{"name": "Electronics", "slug": "electronics", "description": "Electronic devices"}'
# Create a product
curl -X POST http://localhost:8000/products/ \
-H "Content-Type: application/json" \
-d '{
"name": "Wireless Headphones",
"slug": "wireless-headphones",
"price": 79.99,
"sku": "WH-001",
"stock_quantity": 50,
"category_id": 1
}'
# Create a user
curl -X POST http://localhost:8000/users/ \
-H "Content-Type: application/json" \
-d '{
"username": "johndoe",
"email": "john@example.com",
"password": "SecurePass123",
"full_name": "John Doe"
}'
# Create an order
curl -X POST http://localhost:8000/orders/ \
-H "Content-Type: application/json" \
-d '{
"user_id": 1,
"items": [
{"product_id": 1, "quantity": 2}
]
}'
# Get order details
curl http://localhost:8000/orders/1
# Update order status
curl -X PATCH "http://localhost:8000/orders/1/status?new_status=confirmed"
# Get order statistics
curl http://localhost:8000/orders/stats/summary
# Check database health
curl http://localhost:8000/health
In this comprehensive tutorial, we covered every essential aspect of database integration in FastAPI. Here is a summary of the key concepts and recommendations:
| Topic | Key Points |
|---|---|
| Database Choice | Use SQLite for development, PostgreSQL for production. SQLAlchemy makes switching easy. |
| SQLAlchemy Setup | Use DeclarativeBase, configure the engine with pooling, and create a session factory. |
| Models | Use Mapped type annotations (SQLAlchemy 2.0). Define proper indexes and constraints. |
| Session Management | Use FastAPI’s dependency injection with yield for automatic cleanup. |
| CRUD Operations | Use db.flush() for intermediate IDs, db.commit() at transaction boundaries. |
| Schemas vs Models | Separate Pydantic schemas from SQLAlchemy models. Use from_attributes=True. |
| Alembic Migrations | Always use Alembic in production. Never use create_all() in production. |
| Async Database | Use async for high-traffic APIs. Stick with sync for simpler applications. |
| Connection Pooling | Configure pool_size, max_overflow, and pool_pre_ping for production. |
| Relationships | Use selectinload for collections, joinedload for single objects. Avoid N+1. |
| Transactions | Keep transactions short. Use savepoints for partial failure tolerance. |
| Repository Pattern | Abstract queries into repositories. Inject them via FastAPI dependencies. |
Building a REST API is one of the most common use cases for FastAPI. Its combination of automatic validation, serialization, and interactive documentation makes it the ideal framework for creating robust, production-ready APIs. In this tutorial, we will cover everything you need to know to build a complete REST API with FastAPI — from fundamental REST principles to advanced patterns like pagination, filtering, versioning, background tasks, and middleware.
pip install fastapi uvicorn sqlalchemy pydantic[email] python-multipart
REST (Representational State Transfer) is an architectural style for designing networked applications. It was introduced by Roy Fielding in his 2000 doctoral dissertation and has become the dominant approach for building web APIs. Understanding REST principles is essential before writing any API code.
A truly RESTful API adheres to six architectural constraints:
| Constraint | Description | Benefit |
|---|---|---|
| Client-Server | Separation of concerns between UI and data storage | Independent evolution of client and server |
| Stateless | Each request contains all information needed to process it | Scalability — any server can handle any request |
| Cacheable | Responses must define themselves as cacheable or not | Reduced latency and server load |
| Uniform Interface | Standard methods (GET, POST, PUT, DELETE) on resources | Simplicity and decoupling |
| Layered System | Client cannot tell if connected directly to server | Load balancing, caching proxies, security |
| Code on Demand (optional) | Server can extend client functionality with code | Reduced pre-implemented features needed |
In REST, everything is a resource. A resource is any concept that can be addressed — a user, a book, an order, a collection of items. Each resource is identified by a URI (Uniform Resource Identifier).
Good REST URI design follows these conventions:
| Pattern | Example | Description |
|---|---|---|
| Collection | /api/v1/books |
All books (plural noun) |
| Single resource | /api/v1/books/42 |
Book with ID 42 |
| Sub-resource | /api/v1/books/42/reviews |
Reviews belonging to book 42 |
| Filtered collection | /api/v1/books?genre=fiction |
Books filtered by genre |
| Nested sub-resource | /api/v1/users/5/orders/10 |
Order 10 of user 5 |
/api/getBooks — use /api/books with GET method instead/api/book — use /api/books (collections are plural)/api/v1/users/5/orders/10/items/3/reviews — flatten it/api/books/delete/42 — use DELETE /api/books/42 insteadREST maps CRUD operations to HTTP methods. Each method has specific semantics regarding safety and idempotency:
| Method | CRUD | Safe | Idempotent | Request Body | Typical Status Codes |
|---|---|---|---|---|---|
| GET | Read | Yes | Yes | No | 200, 404 |
| POST | Create | No | No | Yes | 201, 400, 409 |
| PUT | Replace | No | Yes | Yes | 200, 204, 404 |
| PATCH | Partial Update | No | No | Yes | 200, 204, 404 |
| DELETE | Delete | No | Yes | Optional | 204, 404 |
| HEAD | Read (headers only) | Yes | Yes | No | 200, 404 |
| OPTIONS | Metadata | Yes | Yes | No | 200, 204 |
Safe means the method does not modify server state. Idempotent means calling it multiple times produces the same result as calling it once. Note that POST is neither safe nor idempotent — calling POST /books twice creates two books.
FastAPI maps naturally to REST principles. Here is a minimal example showing how REST concepts translate to FastAPI code:
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel
from typing import Optional
app = FastAPI(title="REST Principles Demo")
# In-memory storage for demonstration
books_db: dict[int, dict] = {}
next_id: int = 1
class BookCreate(BaseModel):
title: str
author: str
isbn: str
class BookUpdate(BaseModel):
title: Optional[str] = None
author: Optional[str] = None
isbn: Optional[str] = None
# GET /books - Read collection (Safe, Idempotent)
@app.get("/books")
def list_books():
return list(books_db.values())
# GET /books/{id} - Read single resource (Safe, Idempotent)
@app.get("/books/{book_id}")
def get_book(book_id: int):
if book_id not in books_db:
raise HTTPException(status_code=404, detail="Book not found")
return books_db[book_id]
# POST /books - Create resource (Not safe, Not idempotent)
@app.post("/books", status_code=status.HTTP_201_CREATED)
def create_book(book: BookCreate):
global next_id
book_data = {"id": next_id, **book.model_dump()}
books_db[next_id] = book_data
next_id += 1
return book_data
# PUT /books/{id} - Replace resource (Not safe, Idempotent)
@app.put("/books/{book_id}")
def replace_book(book_id: int, book: BookCreate):
if book_id not in books_db:
raise HTTPException(status_code=404, detail="Book not found")
books_db[book_id] = {"id": book_id, **book.model_dump()}
return books_db[book_id]
# PATCH /books/{id} - Partial update (Not safe, Not idempotent)
@app.patch("/books/{book_id}")
def update_book(book_id: int, book: BookUpdate):
if book_id not in books_db:
raise HTTPException(status_code=404, detail="Book not found")
stored = books_db[book_id]
update_data = book.model_dump(exclude_unset=True)
stored.update(update_data)
return stored
# DELETE /books/{id} - Delete resource (Not safe, Idempotent)
@app.delete("/books/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_book(book_id: int):
if book_id not in books_db:
raise HTTPException(status_code=404, detail="Book not found")
del books_db[book_id]
This example demonstrates the fundamental REST pattern: resources (books) are accessed via a uniform interface (HTTP methods) at consistent URIs. FastAPI handles JSON serialization, request validation, and documentation automatically.
Pydantic models are the backbone of request and response handling in FastAPI. They provide automatic validation, serialization, and documentation generation. A well-designed set of Pydantic models is the difference between a fragile API and a robust one.
A common best practice is to separate your models by purpose. You typically need different schemas for creating, updating, and reading a resource:
from pydantic import BaseModel, Field, EmailStr, ConfigDict
from typing import Optional
from datetime import datetime
from enum import Enum
class UserRole(str, Enum):
ADMIN = "admin"
EDITOR = "editor"
VIEWER = "viewer"
# Base schema — shared fields
class UserBase(BaseModel):
username: str = Field(
...,
min_length=3,
max_length=50,
pattern=r"^[a-zA-Z0-9_]+$",
description="Alphanumeric username, 3-50 characters"
)
email: EmailStr = Field(..., description="Valid email address")
full_name: str = Field(..., min_length=1, max_length=100)
role: UserRole = Field(default=UserRole.VIEWER)
# Create schema — fields needed for creation
class UserCreate(UserBase):
password: str = Field(
...,
min_length=8,
max_length=128,
description="Password must be at least 8 characters"
)
# Update schema — all fields optional
class UserUpdate(BaseModel):
username: Optional[str] = Field(
None, min_length=3, max_length=50, pattern=r"^[a-zA-Z0-9_]+$"
)
email: Optional[EmailStr] = None
full_name: Optional[str] = Field(None, min_length=1, max_length=100)
role: Optional[UserRole] = None
# Response schema — what the API returns (never includes password)
class UserResponse(UserBase):
id: int
is_active: bool = True
created_at: datetime
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
# List response with metadata
class UserListResponse(BaseModel):
users: list[UserResponse]
total: int
page: int
per_page: int
This pattern — Base, Create, Update, Response — keeps your API clean and secure. The password field only appears in UserCreate, never in responses.
Real-world APIs frequently deal with nested data. Pydantic handles this naturally:
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
class Address(BaseModel):
street: str
city: str
state: str = Field(..., min_length=2, max_length=2)
zip_code: str = Field(..., pattern=r"^\d{5}(-\d{4})?$")
country: str = "US"
class ContactInfo(BaseModel):
phone: Optional[str] = Field(
None, pattern=r"^\+?1?\d{10,15}$"
)
address: Optional[Address] = None
class AuthorCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
bio: Optional[str] = Field(None, max_length=2000)
contact: Optional[ContactInfo] = None
class BookCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=300)
isbn: str = Field(..., pattern=r"^\d{13}$")
price: float = Field(..., gt=0, le=10000)
author: AuthorCreate
tags: list[str] = Field(default_factory=list, max_length=10)
metadata: dict[str, str] = Field(default_factory=dict)
class BookResponse(BaseModel):
id: int
title: str
isbn: str
price: float
author: AuthorCreate
tags: list[str]
created_at: datetime
model_config = ConfigDict(from_attributes=True)
FastAPI will validate the entire nested structure automatically. If address.zip_code does not match the regex, the client receives a clear 422 error with the exact path to the invalid field.
Pydantic v2 provides powerful validation through Field constraints and custom validators:
from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Optional
from datetime import date
class EventCreate(BaseModel):
name: str = Field(..., min_length=3, max_length=200)
description: Optional[str] = Field(None, max_length=5000)
start_date: date
end_date: date
max_attendees: int = Field(default=100, ge=1, le=10000)
ticket_price: float = Field(default=0.0, ge=0)
tags: list[str] = Field(default_factory=list)
# Field-level validator
@field_validator("name")
@classmethod
def name_must_not_be_blank(cls, v: str) -> str:
if not v.strip():
raise ValueError("Name cannot be blank or whitespace only")
return v.strip()
@field_validator("tags")
@classmethod
def validate_tags(cls, v: list[str]) -> list[str]:
if len(v) > 10:
raise ValueError("Maximum 10 tags allowed")
# Normalize tags to lowercase
return [tag.lower().strip() for tag in v if tag.strip()]
# Model-level validator — access multiple fields
@model_validator(mode="after")
def validate_dates(self) -> "EventCreate":
if self.end_date < self.start_date:
raise ValueError("end_date must be after start_date")
if self.start_date < date.today():
raise ValueError("start_date cannot be in the past")
return self
@model_validator(mode="after")
def validate_free_event_limit(self) -> "EventCreate":
if self.ticket_price == 0 and self.max_attendees > 500:
raise ValueError(
"Free events are limited to 500 attendees"
)
return self
Pydantic v2 supports computed fields that are included in the serialized output but not required as input:
from pydantic import BaseModel, Field, computed_field
from datetime import datetime, date
class ProductResponse(BaseModel):
id: int
name: str
price: float
discount_percent: float = 0.0
created_at: datetime
@computed_field
@property
def discounted_price(self) -> float:
return round(self.price * (1 - self.discount_percent / 100), 2)
@computed_field
@property
def is_new(self) -> bool:
days_since_created = (datetime.now() - self.created_at).days
return days_since_created < 30
@computed_field
@property
def price_tier(self) -> str:
if self.discounted_price < 10:
return "budget"
elif self.discounted_price < 50:
return "mid-range"
else:
return "premium"
# Usage in an endpoint
@app.get("/products/{product_id}", response_model=ProductResponse)
def get_product(product_id: int):
product = get_product_from_db(product_id)
return product # computed fields are auto-calculated
Control how your models behave with model_config:
from pydantic import BaseModel, ConfigDict
class StrictBookCreate(BaseModel):
model_config = ConfigDict(
# Raise error on extra fields
extra="forbid",
# Strip whitespace from strings
str_strip_whitespace=True,
# Validate field defaults
validate_default=True,
# Allow population from ORM objects
from_attributes=True,
# Custom JSON schema example
json_schema_extra={
"examples": [
{
"title": "The Great Gatsby",
"author": "F. Scott Fitzgerald",
"isbn": "9780743273565",
"price": 14.99,
}
]
},
)
title: str
author: str
isbn: str
price: float
The extra="forbid" setting is particularly useful — it rejects requests that include unexpected fields, preventing clients from accidentally sending data you do not handle.
CRUD — Create, Read, Update, Delete — forms the foundation of any REST API. In this section, we will build a complete CRUD implementation with proper error handling, validation, and response formatting. We will use an in-memory store for simplicity, but the patterns apply equally to database-backed APIs.
# models.py
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional
from datetime import datetime
from enum import Enum
class BookStatus(str, Enum):
AVAILABLE = "available"
CHECKED_OUT = "checked_out"
RESERVED = "reserved"
ARCHIVED = "archived"
class BookBase(BaseModel):
title: str = Field(..., min_length=1, max_length=300)
author: str = Field(..., min_length=1, max_length=200)
isbn: str = Field(..., pattern=r"^\d{10}(\d{3})?$")
genre: Optional[str] = Field(None, max_length=50)
published_year: Optional[int] = Field(None, ge=1000, le=2030)
price: float = Field(..., gt=0, le=10000)
description: Optional[str] = Field(None, max_length=5000)
class BookCreate(BookBase):
"""Schema for creating a new book."""
pass
class BookUpdate(BaseModel):
"""Schema for partial updates — all fields optional."""
title: Optional[str] = Field(None, min_length=1, max_length=300)
author: Optional[str] = Field(None, min_length=1, max_length=200)
isbn: Optional[str] = Field(None, pattern=r"^\d{10}(\d{3})?$")
genre: Optional[str] = Field(None, max_length=50)
published_year: Optional[int] = Field(None, ge=1000, le=2030)
price: Optional[float] = Field(None, gt=0, le=10000)
description: Optional[str] = Field(None, max_length=5000)
status: Optional[BookStatus] = None
class BookResponse(BookBase):
"""Schema for book responses."""
id: int
status: BookStatus = BookStatus.AVAILABLE
created_at: datetime
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
# store.py
from datetime import datetime
from typing import Optional
class BookStore:
"""Simple in-memory data store for books."""
def __init__(self):
self._books: dict[int, dict] = {}
self._next_id: int = 1
def create(self, data: dict) -> dict:
book_id = self._next_id
self._next_id += 1
book = {
"id": book_id,
**data,
"status": "available",
"created_at": datetime.utcnow(),
"updated_at": None,
}
self._books[book_id] = book
return book
def get(self, book_id: int) -> Optional[dict]:
return self._books.get(book_id)
def get_all(self) -> list[dict]:
return list(self._books.values())
def update(self, book_id: int, data: dict) -> Optional[dict]:
if book_id not in self._books:
return None
book = self._books[book_id]
book.update(data)
book["updated_at"] = datetime.utcnow()
return book
def delete(self, book_id: int) -> bool:
if book_id not in self._books:
return False
del self._books[book_id]
return True
def exists(self, book_id: int) -> bool:
return book_id in self._books
def find_by_isbn(self, isbn: str) -> Optional[dict]:
for book in self._books.values():
if book["isbn"] == isbn:
return book
return None
def count(self) -> int:
return len(self._books)
# Global instance
book_store = BookStore()
from fastapi import FastAPI, HTTPException, status, Response
from models import BookCreate, BookUpdate, BookResponse
from store import book_store
app = FastAPI(title="Book Management API")
@app.post(
"/api/v1/books",
response_model=BookResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a new book",
description="Add a new book to the catalog. ISBN must be unique.",
)
def create_book(book: BookCreate):
# Check for duplicate ISBN
existing = book_store.find_by_isbn(book.isbn)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Book with ISBN {book.isbn} already exists (ID: {existing['id']})",
)
book_data = book.model_dump()
created_book = book_store.create(book_data)
return created_book
Key points about the Create endpoint:
response_model=BookResponse ensures the response matches our schemafrom typing import Optional
@app.get(
"/api/v1/books",
response_model=list[BookResponse],
summary="List all books",
)
def list_books(
genre: Optional[str] = None,
author: Optional[str] = None,
status_filter: Optional[str] = None,
):
"""Retrieve all books, optionally filtered by genre, author, or status."""
books = book_store.get_all()
if genre:
books = [b for b in books if b.get("genre", "").lower() == genre.lower()]
if author:
books = [b for b in books if author.lower() in b["author"].lower()]
if status_filter:
books = [b for b in books if b["status"] == status_filter]
return books
@app.get(
"/api/v1/books/{book_id}",
response_model=BookResponse,
summary="Get a specific book",
)
def get_book(book_id: int):
"""Retrieve a single book by its ID."""
book = book_store.get(book_id)
if not book:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Book with ID {book_id} not found",
)
return book
REST distinguishes between PUT (full replacement) and PATCH (partial update). Both are important:
# PUT — Full replacement (all required fields must be provided)
@app.put(
"/api/v1/books/{book_id}",
response_model=BookResponse,
summary="Replace a book",
)
def replace_book(book_id: int, book: BookCreate):
"""Replace all fields of an existing book."""
if not book_store.exists(book_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Book with ID {book_id} not found",
)
# Check ISBN conflict (but allow same book to keep its ISBN)
existing = book_store.find_by_isbn(book.isbn)
if existing and existing["id"] != book_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"ISBN {book.isbn} already belongs to book ID {existing['id']}",
)
updated = book_store.update(book_id, book.model_dump())
return updated
# PATCH — Partial update (only provided fields are updated)
@app.patch(
"/api/v1/books/{book_id}",
response_model=BookResponse,
summary="Update a book partially",
)
def update_book(book_id: int, book: BookUpdate):
"""Update specific fields of an existing book."""
if not book_store.exists(book_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Book with ID {book_id} not found",
)
# Only include fields that were explicitly set
update_data = book.model_dump(exclude_unset=True)
if not update_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No fields provided for update",
)
# Check ISBN conflict if ISBN is being updated
if "isbn" in update_data:
existing = book_store.find_by_isbn(update_data["isbn"])
if existing and existing["id"] != book_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"ISBN {update_data['isbn']} already belongs to book ID {existing['id']}",
)
updated = book_store.update(book_id, update_data)
return updated
None vs fields not provided at all are handled differently — model_dump(exclude_unset=True) only returns fields the client actually sent.@app.delete(
"/api/v1/books/{book_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a book",
)
def delete_book(book_id: int):
"""Remove a book from the catalog."""
if not book_store.delete(book_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Book with ID {book_id} not found",
)
# 204 No Content — return nothing
return Response(status_code=status.HTTP_204_NO_CONTENT)
Production APIs often need bulk create and delete operations:
from pydantic import BaseModel
class BulkCreateResponse(BaseModel):
created: list[BookResponse]
errors: list[dict]
@app.post(
"/api/v1/books/bulk",
response_model=BulkCreateResponse,
status_code=status.HTTP_201_CREATED,
summary="Create multiple books",
)
def bulk_create_books(books: list[BookCreate]):
"""Create multiple books in a single request. Returns created books and any errors."""
if len(books) > 100:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Maximum 100 books per bulk request",
)
created = []
errors = []
for i, book in enumerate(books):
existing = book_store.find_by_isbn(book.isbn)
if existing:
errors.append({
"index": i,
"isbn": book.isbn,
"error": f"Duplicate ISBN (existing ID: {existing['id']})",
})
continue
book_data = book.model_dump()
created_book = book_store.create(book_data)
created.append(created_book)
return {"created": created, "errors": errors}
class BulkDeleteRequest(BaseModel):
ids: list[int] = Field(..., min_length=1, max_length=100)
class BulkDeleteResponse(BaseModel):
deleted: list[int]
not_found: list[int]
@app.post(
"/api/v1/books/bulk-delete",
response_model=BulkDeleteResponse,
summary="Delete multiple books",
)
def bulk_delete_books(request: BulkDeleteRequest):
"""Delete multiple books by ID. Returns which IDs were deleted and which were not found."""
deleted = []
not_found = []
for book_id in request.ids:
if book_store.delete(book_id):
deleted.append(book_id)
else:
not_found.append(book_id)
return {"deleted": deleted, "not_found": not_found}
Note that we use POST /books/bulk-delete instead of DELETE /books because DELETE with a request body is not universally supported by all HTTP clients and proxies.
FastAPI’s response_model parameter gives you fine-grained control over what data is returned to clients. It filters out sensitive fields, adds computed properties, and generates accurate OpenAPI documentation.
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr, ConfigDict
from typing import Optional
from datetime import datetime
app = FastAPI()
class UserInternal(BaseModel):
"""Internal user model — contains sensitive data."""
id: int
username: str
email: str
hashed_password: str
api_key: str
is_active: bool
login_attempts: int
created_at: datetime
class UserPublic(BaseModel):
"""Public user model — safe to return to clients."""
id: int
username: str
email: str
is_active: bool
created_at: datetime
model_config = ConfigDict(from_attributes=True)
# The response_model filters out sensitive fields automatically
@app.get("/users/{user_id}", response_model=UserPublic)
def get_user(user_id: int):
# Even though our internal data includes hashed_password and api_key,
# the response_model ensures they are never sent to the client
user = get_user_from_db(user_id) # Returns UserInternal
return user
Even if the function returns an object with extra fields (like hashed_password), the response_model strips them from the response. This is a critical security feature.
For quick filtering without creating separate models, use response_model_exclude and response_model_include:
class Book(BaseModel):
id: int
title: str
author: str
isbn: str
price: float
description: str
internal_notes: str
created_at: datetime
# Exclude specific fields
@app.get(
"/books/{book_id}",
response_model=Book,
response_model_exclude={"internal_notes"},
)
def get_book(book_id: int):
return books_db[book_id]
# Include only specific fields (useful for summary views)
@app.get(
"/books/{book_id}/summary",
response_model=Book,
response_model_include={"id", "title", "author", "price"},
)
def get_book_summary(book_id: int):
return books_db[book_id]
# Exclude None values from response
@app.get(
"/books/{book_id}/compact",
response_model=Book,
response_model_exclude_none=True,
)
def get_book_compact(book_id: int):
"""Only include fields that have non-None values."""
return books_db[book_id]
# Exclude default values
@app.get(
"/books/{book_id}/minimal",
response_model=Book,
response_model_exclude_defaults=True,
)
def get_book_minimal(book_id: int):
"""Only include fields that differ from their defaults."""
return books_db[book_id]
Different endpoints often need different views of the same resource:
from pydantic import BaseModel, ConfigDict
from typing import Optional
from datetime import datetime
class BookSummary(BaseModel):
"""Minimal view for list endpoints."""
id: int
title: str
author: str
price: float
model_config = ConfigDict(from_attributes=True)
class BookDetail(BaseModel):
"""Full view for detail endpoints."""
id: int
title: str
author: str
isbn: str
price: float
genre: Optional[str]
published_year: Optional[int]
description: Optional[str]
page_count: Optional[int]
created_at: datetime
updated_at: Optional[datetime]
model_config = ConfigDict(from_attributes=True)
class BookAdmin(BookDetail):
"""Admin view with internal fields."""
internal_notes: Optional[str]
cost_price: float
profit_margin: float
supplier: Optional[str]
# List endpoint returns summaries
@app.get("/api/v1/books", response_model=list[BookSummary])
def list_books():
return book_store.get_all()
# Detail endpoint returns full data
@app.get("/api/v1/books/{book_id}", response_model=BookDetail)
def get_book(book_id: int):
return book_store.get(book_id)
# Admin endpoint returns everything
@app.get("/api/v1/admin/books/{book_id}", response_model=BookAdmin)
def get_book_admin(book_id: int):
return book_store.get(book_id)
Most production APIs wrap their responses in a standard envelope for consistency:
from pydantic import BaseModel
from typing import TypeVar, Generic, Optional
from datetime import datetime
T = TypeVar("T")
class APIResponse(BaseModel, Generic[T]):
"""Standard API response envelope."""
success: bool = True
data: T
message: Optional[str] = None
timestamp: datetime = Field(default_factory=datetime.utcnow)
class PaginatedResponse(BaseModel, Generic[T]):
"""Paginated response with metadata."""
items: list[T]
total: int
page: int
per_page: int
total_pages: int
class ErrorResponse(BaseModel):
"""Standard error response."""
success: bool = False
error: str
detail: Optional[str] = None
timestamp: datetime = Field(default_factory=datetime.utcnow)
# Usage
@app.get(
"/api/v1/books/{book_id}",
response_model=APIResponse[BookDetail],
)
def get_book(book_id: int):
book = book_store.get(book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
return APIResponse(data=book, message="Book retrieved successfully")
@app.get(
"/api/v1/books",
response_model=APIResponse[PaginatedResponse[BookSummary]],
)
def list_books(page: int = 1, per_page: int = 20):
all_books = book_store.get_all()
total = len(all_books)
start = (page - 1) * per_page
end = start + per_page
items = all_books[start:end]
total_pages = (total + per_page - 1) // per_page
paginated = PaginatedResponse(
items=items,
total=total,
page=page,
per_page=per_page,
total_pages=total_pages,
)
return APIResponse(data=paginated)
Using generic wrapper models gives your API a consistent feel. Clients always know the shape of the response — success, data, message — regardless of the endpoint.
Proper HTTP status codes and error handling are what separate amateur APIs from professional ones. Clients depend on status codes for control flow, and clear error messages reduce debugging time dramatically.
| Code | Name | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST that created a resource |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Invalid request syntax or parameters |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Authenticated but not authorized |
| 404 | Not Found | Resource does not exist |
| 409 | Conflict | Duplicate resource (e.g., duplicate email) |
| 422 | Unprocessable Entity | Validation error (FastAPI default) |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unexpected server error |
FastAPI provides HTTPException for raising errors with appropriate status codes:
from fastapi import FastAPI, HTTPException, status
app = FastAPI()
@app.get("/books/{book_id}")
def get_book(book_id: int):
book = book_store.get(book_id)
if not book:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Book not found",
headers={"X-Error": "Book lookup failed"},
)
return book
@app.post("/books")
def create_book(book: BookCreate):
# Business logic validation
if book.price > 1000 and not book.isbn.startswith("978"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"message": "Premium books must have a valid ISBN-13",
"field": "isbn",
"constraint": "Must start with 978 for books over $1000",
},
)
return book_store.create(book.model_dump())
The detail parameter can be a string or a dictionary — use dictionaries for structured error responses.
For larger applications, define custom exception classes and register handlers:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from datetime import datetime
# Custom exception classes
class AppException(Exception):
"""Base exception for the application."""
def __init__(self, message: str, status_code: int = 500):
self.message = message
self.status_code = status_code
class NotFoundException(AppException):
def __init__(self, resource: str, resource_id):
super().__init__(
message=f"{resource} with ID {resource_id} not found",
status_code=404,
)
self.resource = resource
self.resource_id = resource_id
class DuplicateException(AppException):
def __init__(self, resource: str, field: str, value: str):
super().__init__(
message=f"{resource} with {field}='{value}' already exists",
status_code=409,
)
class ValidationException(AppException):
def __init__(self, errors: list[dict]):
super().__init__(message="Validation failed", status_code=422)
self.errors = errors
class RateLimitException(AppException):
def __init__(self, retry_after: int = 60):
super().__init__(
message="Rate limit exceeded",
status_code=429,
)
self.retry_after = retry_after
app = FastAPI()
# Register exception handlers
@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException):
return JSONResponse(
status_code=exc.status_code,
content={
"success": False,
"error": type(exc).__name__,
"message": exc.message,
"path": str(request.url),
"timestamp": datetime.utcnow().isoformat(),
},
)
@app.exception_handler(NotFoundException)
async def not_found_handler(request: Request, exc: NotFoundException):
return JSONResponse(
status_code=404,
content={
"success": False,
"error": "NotFound",
"message": exc.message,
"resource": exc.resource,
"resource_id": str(exc.resource_id),
"path": str(request.url),
"timestamp": datetime.utcnow().isoformat(),
},
)
@app.exception_handler(RateLimitException)
async def rate_limit_handler(request: Request, exc: RateLimitException):
return JSONResponse(
status_code=429,
content={
"success": False,
"error": "RateLimitExceeded",
"message": exc.message,
"retry_after": exc.retry_after,
},
headers={"Retry-After": str(exc.retry_after)},
)
# Usage in endpoints
@app.get("/books/{book_id}")
def get_book(book_id: int):
book = book_store.get(book_id)
if not book:
raise NotFoundException("Book", book_id)
return book
@app.post("/books", status_code=201)
def create_book(book: BookCreate):
existing = book_store.find_by_isbn(book.isbn)
if existing:
raise DuplicateException("Book", "isbn", book.isbn)
return book_store.create(book.model_dump())
FastAPI returns 422 errors for validation failures, but the default format may not match your API style. You can override it:
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
app = FastAPI()
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
):
"""Transform Pydantic validation errors into a cleaner format."""
errors = []
for error in exc.errors():
field_path = " -> ".join(str(loc) for loc in error["loc"])
errors.append({
"field": field_path,
"message": error["msg"],
"type": error["type"],
})
return JSONResponse(
status_code=422,
content={
"success": False,
"error": "ValidationError",
"message": "Request validation failed",
"errors": errors,
"body": exc.body,
},
)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(
request: Request, exc: StarletteHTTPException
):
"""Standardize all HTTP error responses."""
return JSONResponse(
status_code=exc.status_code,
content={
"success": False,
"error": "HTTPError",
"message": str(exc.detail),
"status_code": exc.status_code,
},
)
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
"""Catch-all handler for unexpected errors."""
# Log the error for debugging (never expose details to clients)
import logging
logging.exception(f"Unhandled error at {request.url}")
return JSONResponse(
status_code=500,
content={
"success": False,
"error": "InternalServerError",
"message": "An unexpected error occurred",
},
)
Document your error responses so API consumers know what to expect:
from pydantic import BaseModel
class ErrorDetail(BaseModel):
success: bool = False
error: str
message: str
@app.get(
"/books/{book_id}",
response_model=BookResponse,
responses={
200: {"description": "Book found successfully"},
404: {
"model": ErrorDetail,
"description": "Book not found",
"content": {
"application/json": {
"example": {
"success": False,
"error": "NotFound",
"message": "Book with ID 42 not found",
}
}
},
},
422: {
"model": ErrorDetail,
"description": "Validation error",
},
},
)
def get_book(book_id: int):
book = book_store.get(book_id)
if not book:
raise NotFoundException("Book", book_id)
return book
Any API that returns collections must implement pagination. Without it, a list endpoint could return millions of records, overwhelming both the server and client. FastAPI makes it straightforward to implement multiple pagination strategies.
The simplest and most common pagination style uses offset (or skip) and limit parameters:
from fastapi import FastAPI, Query
from pydantic import BaseModel
from typing import TypeVar, Generic
from math import ceil
app = FastAPI()
T = TypeVar("T")
class PaginatedResponse(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
per_page: int
total_pages: int
has_next: bool
has_prev: bool
def paginate(
items: list,
page: int,
per_page: int,
) -> dict:
"""Generic pagination helper."""
total = len(items)
total_pages = ceil(total / per_page) if per_page > 0 else 0
start = (page - 1) * per_page
end = start + per_page
return {
"items": items[start:end],
"total": total,
"page": page,
"per_page": per_page,
"total_pages": total_pages,
"has_next": page < total_pages,
"has_prev": page > 1,
}
@app.get("/api/v1/books", response_model=PaginatedResponse[BookSummary])
def list_books(
page: int = Query(1, ge=1, description="Page number"),
per_page: int = Query(20, ge=1, le=100, description="Items per page"),
):
"""List books with offset/limit pagination."""
all_books = book_store.get_all()
return paginate(all_books, page, per_page)
To avoid repeating pagination parameters on every endpoint, create a reusable dependency:
from fastapi import Depends, Query
from dataclasses import dataclass
@dataclass
class PaginationParams:
"""Reusable pagination parameters."""
page: int = Query(1, ge=1, description="Page number (1-based)")
per_page: int = Query(20, ge=1, le=100, description="Items per page")
@property
def offset(self) -> int:
return (self.page - 1) * self.per_page
@property
def limit(self) -> int:
return self.per_page
@app.get("/api/v1/books")
def list_books(pagination: PaginationParams = Depends()):
all_books = book_store.get_all()
total = len(all_books)
items = all_books[pagination.offset : pagination.offset + pagination.limit]
return {
"items": items,
"total": total,
"page": pagination.page,
"per_page": pagination.per_page,
"total_pages": ceil(total / pagination.per_page),
}
@app.get("/api/v1/authors")
def list_authors(pagination: PaginationParams = Depends()):
# Same pagination logic, different resource
all_authors = author_store.get_all()
total = len(all_authors)
items = all_authors[pagination.offset : pagination.offset + pagination.limit]
return {
"items": items,
"total": total,
"page": pagination.page,
"per_page": pagination.per_page,
}
Cursor-based pagination is more efficient for large datasets and avoids the “shifting window” problem of offset pagination (where new inserts cause items to appear on multiple pages):
from pydantic import BaseModel
from typing import Optional
import base64
import json
class CursorPage(BaseModel):
items: list[dict]
next_cursor: Optional[str] = None
prev_cursor: Optional[str] = None
has_more: bool
def encode_cursor(book_id: int, created_at: str) -> str:
"""Encode pagination cursor as base64 JSON."""
data = {"id": book_id, "created_at": created_at}
return base64.urlsafe_b64encode(
json.dumps(data).encode()
).decode()
def decode_cursor(cursor: str) -> dict:
"""Decode a pagination cursor."""
try:
data = json.loads(
base64.urlsafe_b64decode(cursor.encode()).decode()
)
return data
except Exception:
raise HTTPException(
status_code=400, detail="Invalid cursor format"
)
@app.get("/api/v1/books/cursor", response_model=CursorPage)
def list_books_cursor(
cursor: Optional[str] = Query(None, description="Pagination cursor"),
limit: int = Query(20, ge=1, le=100),
):
"""List books with cursor-based pagination."""
all_books = sorted(
book_store.get_all(),
key=lambda b: (b["created_at"], b["id"]),
)
# If cursor provided, find the starting point
start_index = 0
if cursor:
cursor_data = decode_cursor(cursor)
for i, book in enumerate(all_books):
if book["id"] == cursor_data["id"]:
start_index = i + 1
break
# Get one extra item to determine if there are more
items = all_books[start_index : start_index + limit + 1]
has_more = len(items) > limit
items = items[:limit]
# Build cursors
next_cursor = None
if has_more and items:
last = items[-1]
next_cursor = encode_cursor(
last["id"], last["created_at"].isoformat()
)
prev_cursor = None
if start_index > 0 and items:
first = items[0]
prev_cursor = encode_cursor(
first["id"], first["created_at"].isoformat()
)
return CursorPage(
items=items,
next_cursor=next_cursor,
prev_cursor=prev_cursor,
has_more=has_more,
)
Following the RFC 8288 standard, you can include pagination links in response headers:
from fastapi import Response
from math import ceil
@app.get("/api/v1/books")
def list_books(
response: Response,
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
):
all_books = book_store.get_all()
total = len(all_books)
total_pages = ceil(total / per_page)
start = (page - 1) * per_page
items = all_books[start : start + per_page]
# Build Link header
base_url = "/api/v1/books"
links = []
if page < total_pages:
links.append(
f'<{base_url}?page={page + 1}&per_page={per_page}>; rel="next"'
)
if page > 1:
links.append(
f'<{base_url}?page={page - 1}&per_page={per_page}>; rel="prev"'
)
links.append(
f'<{base_url}?page=1&per_page={per_page}>; rel="first"'
)
links.append(
f'<{base_url}?page={total_pages}&per_page={per_page}>; rel="last"'
)
if links:
response.headers["Link"] = ", ".join(links)
response.headers["X-Total-Count"] = str(total)
response.headers["X-Total-Pages"] = str(total_pages)
return {"items": items, "total": total, "page": page, "per_page": per_page}
| Strategy | Best For | Drawbacks |
|---|---|---|
| Offset/Limit | Simple lists, admin panels, small datasets | Slow on large offsets, shifting window problem |
| Cursor-based | Real-time feeds, large datasets, infinite scroll | Cannot jump to arbitrary page |
| Keyset (ID-based) | Chronological data, activity logs | Requires stable sort order |
Clients need to find specific subsets of data efficiently. Well-designed filtering and sorting makes your API dramatically more useful.
from fastapi import FastAPI, Query
from typing import Optional
from enum import Enum
app = FastAPI()
class SortField(str, Enum):
TITLE = "title"
AUTHOR = "author"
PRICE = "price"
CREATED = "created_at"
YEAR = "published_year"
class SortOrder(str, Enum):
ASC = "asc"
DESC = "desc"
@app.get("/api/v1/books")
def list_books(
# Text search
search: Optional[str] = Query(
None, min_length=2, description="Search in title and author"
),
# Exact match filters
genre: Optional[str] = Query(None, description="Filter by genre"),
author: Optional[str] = Query(None, description="Filter by author name"),
status: Optional[str] = Query(None, description="Filter by status"),
# Range filters
min_price: Optional[float] = Query(None, ge=0, description="Minimum price"),
max_price: Optional[float] = Query(None, ge=0, description="Maximum price"),
published_after: Optional[int] = Query(None, description="Published year minimum"),
published_before: Optional[int] = Query(None, description="Published year maximum"),
# Sorting
sort_by: SortField = Query(SortField.CREATED, description="Sort field"),
sort_order: SortOrder = Query(SortOrder.DESC, description="Sort direction"),
# Pagination
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
):
books = book_store.get_all()
# Apply text search
if search:
search_lower = search.lower()
books = [
b for b in books
if search_lower in b["title"].lower()
or search_lower in b["author"].lower()
]
# Apply exact match filters
if genre:
books = [b for b in books if b.get("genre", "").lower() == genre.lower()]
if author:
books = [b for b in books if author.lower() in b["author"].lower()]
if status:
books = [b for b in books if b.get("status") == status]
# Apply range filters
if min_price is not None:
books = [b for b in books if b["price"] >= min_price]
if max_price is not None:
books = [b for b in books if b["price"] <= max_price]
if published_after is not None:
books = [
b for b in books
if b.get("published_year") and b["published_year"] >= published_after
]
if published_before is not None:
books = [
b for b in books
if b.get("published_year") and b["published_year"] <= published_before
]
# Apply sorting
reverse = sort_order == SortOrder.DESC
books.sort(
key=lambda b: b.get(sort_by.value, ""),
reverse=reverse,
)
# Apply pagination
total = len(books)
start = (page - 1) * per_page
items = books[start : start + per_page]
return {
"items": items,
"total": total,
"page": page,
"per_page": per_page,
"filters_applied": {
"search": search,
"genre": genre,
"author": author,
"min_price": min_price,
"max_price": max_price,
},
}
Extract filtering logic into a reusable dependency class:
from fastapi import Depends, Query
from dataclasses import dataclass, field
from typing import Optional, Callable
@dataclass
class BookFilters:
"""Reusable book filter parameters."""
search: Optional[str] = Query(None, min_length=2)
genre: Optional[str] = None
author: Optional[str] = None
min_price: Optional[float] = Query(None, ge=0)
max_price: Optional[float] = Query(None, ge=0)
published_after: Optional[int] = None
published_before: Optional[int] = None
in_stock: Optional[bool] = None
def apply(self, books: list[dict]) -> list[dict]:
"""Apply all active filters to a list of books."""
result = books
if self.search:
q = self.search.lower()
result = [
b for b in result
if q in b["title"].lower() or q in b["author"].lower()
]
if self.genre:
result = [
b for b in result
if b.get("genre", "").lower() == self.genre.lower()
]
if self.author:
result = [
b for b in result
if self.author.lower() in b["author"].lower()
]
if self.min_price is not None:
result = [b for b in result if b["price"] >= self.min_price]
if self.max_price is not None:
result = [b for b in result if b["price"] <= self.max_price]
if self.published_after is not None:
result = [
b for b in result
if b.get("published_year")
and b["published_year"] >= self.published_after
]
if self.published_before is not None:
result = [
b for b in result
if b.get("published_year")
and b["published_year"] <= self.published_before
]
return result
@dataclass
class SortParams:
"""Reusable sort parameters."""
sort_by: SortField = Query(SortField.CREATED)
sort_order: SortOrder = Query(SortOrder.DESC)
def apply(self, items: list[dict]) -> list[dict]:
reverse = self.sort_order == SortOrder.DESC
return sorted(
items,
key=lambda x: x.get(self.sort_by.value, ""),
reverse=reverse,
)
# Clean endpoint using dependencies
@app.get("/api/v1/books")
def list_books(
filters: BookFilters = Depends(),
sorting: SortParams = Depends(),
pagination: PaginationParams = Depends(),
):
books = book_store.get_all()
books = filters.apply(books)
books = sorting.apply(books)
total = len(books)
items = books[pagination.offset : pagination.offset + pagination.limit]
return {
"items": items,
"total": total,
"page": pagination.page,
"per_page": pagination.per_page,
}
This pattern keeps endpoints clean and DRY. The same BookFilters and SortParams can be reused across multiple endpoints.
Allow filtering by multiple values for the same field:
from fastapi import Query
from typing import Optional
@app.get("/api/v1/books")
def list_books(
# Multiple genres: ?genres=fiction&genres=science
genres: Optional[list[str]] = Query(None, description="Filter by genres"),
# Multiple authors
authors: Optional[list[str]] = Query(None),
# Multiple statuses
statuses: Optional[list[str]] = Query(None),
# Tags (many-to-many)
tags: Optional[list[str]] = Query(
None, description="Books must have ALL specified tags"
),
any_tags: Optional[list[str]] = Query(
None, description="Books must have ANY of specified tags"
),
):
books = book_store.get_all()
if genres:
genres_lower = [g.lower() for g in genres]
books = [
b for b in books
if b.get("genre", "").lower() in genres_lower
]
if authors:
authors_lower = [a.lower() for a in authors]
books = [
b for b in books
if any(a in b["author"].lower() for a in authors_lower)
]
if statuses:
books = [b for b in books if b.get("status") in statuses]
if tags:
# AND logic — book must have ALL tags
tags_set = set(t.lower() for t in tags)
books = [
b for b in books
if tags_set.issubset(set(t.lower() for t in b.get("tags", [])))
]
if any_tags:
# OR logic — book must have at least one tag
any_tags_set = set(t.lower() for t in any_tags)
books = [
b for b in books
if any_tags_set.intersection(
set(t.lower() for t in b.get("tags", []))
)
]
return {"items": books, "total": len(books)}
As your API evolves, you will need to make breaking changes while still supporting existing clients. API versioning lets you introduce new versions without breaking old integrations.
The most common and explicit approach — include the version in the URL path:
from fastapi import FastAPI, APIRouter
app = FastAPI(title="Versioned API")
# Version 1 router
v1_router = APIRouter(prefix="/api/v1", tags=["v1"])
@v1_router.get("/books")
def list_books_v1():
"""V1: Returns basic book data."""
books = book_store.get_all()
return [
{
"id": b["id"],
"title": b["title"],
"author": b["author"],
"price": b["price"],
}
for b in books
]
@v1_router.get("/books/{book_id}")
def get_book_v1(book_id: int):
book = book_store.get(book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
return {
"id": book["id"],
"title": book["title"],
"author": book["author"],
"price": book["price"],
}
# Version 2 router — enhanced response format
v2_router = APIRouter(prefix="/api/v2", tags=["v2"])
@v2_router.get("/books")
def list_books_v2(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
):
"""V2: Returns paginated books with metadata and links."""
books = book_store.get_all()
total = len(books)
start = (page - 1) * per_page
items = books[start : start + per_page]
return {
"data": [
{
"id": b["id"],
"title": b["title"],
"author": {
"name": b["author"],
},
"pricing": {
"amount": b["price"],
"currency": "USD",
},
"metadata": {
"genre": b.get("genre"),
"published_year": b.get("published_year"),
"status": b.get("status", "available"),
},
"links": {
"self": f"/api/v2/books/{b['id']}",
"reviews": f"/api/v2/books/{b['id']}/reviews",
},
}
for b in items
],
"pagination": {
"total": total,
"page": page,
"per_page": per_page,
"total_pages": (total + per_page - 1) // per_page,
},
}
# Register both versions
app.include_router(v1_router)
app.include_router(v2_router)
An alternative approach uses a custom header to specify the API version:
from fastapi import FastAPI, Header, HTTPException
@app.get("/api/books")
def list_books(
api_version: str = Header(
"1", alias="X-API-Version", description="API version"
),
):
"""Route to different implementations based on version header."""
books = book_store.get_all()
if api_version == "1":
return [
{"id": b["id"], "title": b["title"], "author": b["author"]}
for b in books
]
elif api_version == "2":
return {
"data": [
{
"id": b["id"],
"title": b["title"],
"author": {"name": b["author"]},
"pricing": {"amount": b["price"], "currency": "USD"},
}
for b in books
],
"meta": {"version": "2", "total": len(books)},
}
else:
raise HTTPException(
status_code=400,
detail=f"Unsupported API version: {api_version}. Supported: 1, 2",
)
For larger applications, organize versioned code into separate modules:
# Project structure:
# app/
# +-- main.py
# +-- api/
# | +-- __init__.py
# | +-- v1/
# | | +-- __init__.py
# | | +-- router.py
# | | +-- endpoints/
# | | | +-- books.py
# | | | +-- authors.py
# | | +-- schemas.py
# | +-- v2/
# | +-- __init__.py
# | +-- router.py
# | +-- endpoints/
# | | +-- books.py
# | | +-- authors.py
# | +-- schemas.py
# +-- core/
# | +-- config.py
# | +-- database.py
# +-- models/
# +-- book.py
# +-- author.py
# app/api/v1/router.py
from fastapi import APIRouter
from .endpoints import books, authors
router = APIRouter(prefix="/api/v1")
router.include_router(books.router, prefix="/books", tags=["v1-books"])
router.include_router(authors.router, prefix="/authors", tags=["v1-authors"])
# app/api/v2/router.py
from fastapi import APIRouter
from .endpoints import books, authors
router = APIRouter(prefix="/api/v2")
router.include_router(books.router, prefix="/books", tags=["v2-books"])
router.include_router(authors.router, prefix="/authors", tags=["v2-authors"])
# app/main.py
from fastapi import FastAPI
from app.api.v1.router import router as v1_router
from app.api.v2.router import router as v2_router
app = FastAPI(
title="Book Management API",
description="Multi-version REST API",
)
app.include_router(v1_router)
app.include_router(v2_router)
# Deprecation notice for v1
@app.middleware("http")
async def add_deprecation_header(request, call_next):
response = await call_next(request)
if request.url.path.startswith("/api/v1"):
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = "2025-12-31"
response.headers["Link"] = (
'</api/v2>; rel="successor-version"'
)
return response
| Strategy | Pros | Cons |
|---|---|---|
URL path (/v1/books) |
Explicit, easy to test, cacheable | URL pollution, harder to remove versions |
Header (X-API-Version: 2) |
Clean URLs, flexible | Hidden, harder to test, not cacheable |
Query param (?version=2) |
Easy to use, visible | Not standard, pollutes query string |
| Content negotiation | HTTP-standard approach | Complex, poor tooling support |
Some operations should not block the API response — sending emails, processing uploads, generating reports, updating caches. FastAPI provides BackgroundTasks for running code after the response is sent.
from fastapi import FastAPI, BackgroundTasks
from datetime import datetime
import logging
app = FastAPI()
logger = logging.getLogger(__name__)
def send_welcome_email(email: str, username: str):
"""Simulate sending a welcome email (runs in background)."""
logger.info(f"Sending welcome email to {email} for user {username}")
# In production, use an email service like SendGrid, SES, etc.
import time
time.sleep(2) # Simulate slow email API call
logger.info(f"Welcome email sent to {email}")
def log_user_activity(user_id: int, action: str):
"""Log user activity to analytics (runs in background)."""
logger.info(f"Logging activity: user={user_id}, action={action}")
# Write to analytics database, send to event stream, etc.
@app.post("/api/v1/users", status_code=201)
def create_user(user: UserCreate, background_tasks: BackgroundTasks):
"""Create a user and send welcome email in the background."""
# Create user immediately
new_user = user_store.create(user.model_dump())
# Queue background tasks — these run AFTER the response is sent
background_tasks.add_task(
send_welcome_email, new_user["email"], new_user["username"]
)
background_tasks.add_task(
log_user_activity, new_user["id"], "registration"
)
# Response is sent immediately, without waiting for email
return new_user
from fastapi import Depends, BackgroundTasks
import json
from pathlib import Path
class AuditLogger:
"""Service for logging audit events."""
def __init__(self, log_dir: str = "/var/log/api"):
self.log_dir = Path(log_dir)
self.log_dir.mkdir(parents=True, exist_ok=True)
def log_event(
self, event_type: str, resource_type: str,
resource_id: int, user_id: int, details: dict
):
event = {
"timestamp": datetime.utcnow().isoformat(),
"event_type": event_type,
"resource_type": resource_type,
"resource_id": resource_id,
"user_id": user_id,
"details": details,
}
log_file = self.log_dir / f"audit_{datetime.utcnow():%Y%m%d}.jsonl"
with open(log_file, "a") as f:
f.write(json.dumps(event) + "
")
def get_audit_logger():
return AuditLogger()
@app.delete("/api/v1/books/{book_id}", status_code=204)
def delete_book(
book_id: int,
background_tasks: BackgroundTasks,
audit: AuditLogger = Depends(get_audit_logger),
):
book = book_store.get(book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
book_store.delete(book_id)
# Audit logging happens in background
background_tasks.add_task(
audit.log_event,
event_type="delete",
resource_type="book",
resource_id=book_id,
user_id=1, # Would come from auth
details={"title": book["title"], "isbn": book["isbn"]},
)
return Response(status_code=204)
from fastapi import BackgroundTasks
from pydantic import BaseModel
from enum import Enum
import uuid
class ReportStatus(str, Enum):
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
# In-memory report tracker
reports: dict[str, dict] = {}
def generate_report(report_id: str, report_type: str, filters: dict):
"""Generate a report in the background."""
reports[report_id]["status"] = ReportStatus.PROCESSING
try:
# Simulate heavy processing
import time
time.sleep(10)
# Build report data
if report_type == "inventory":
books = book_store.get_all()
report_data = {
"total_books": len(books),
"total_value": sum(b["price"] for b in books),
"by_genre": {},
}
for book in books:
genre = book.get("genre", "unknown")
if genre not in report_data["by_genre"]:
report_data["by_genre"][genre] = 0
report_data["by_genre"][genre] += 1
reports[report_id]["data"] = report_data
reports[report_id]["status"] = ReportStatus.COMPLETED
except Exception as e:
reports[report_id]["status"] = ReportStatus.FAILED
reports[report_id]["error"] = str(e)
class ReportRequest(BaseModel):
report_type: str
filters: dict = {}
@app.post("/api/v1/reports", status_code=202)
def request_report(
request: ReportRequest,
background_tasks: BackgroundTasks,
):
"""Request a report generation. Returns immediately with a report ID."""
report_id = str(uuid.uuid4())
reports[report_id] = {
"id": report_id,
"report_type": request.report_type,
"status": ReportStatus.PENDING,
"created_at": datetime.utcnow().isoformat(),
"data": None,
"error": None,
}
background_tasks.add_task(
generate_report, report_id, request.report_type, request.filters
)
return {
"report_id": report_id,
"status": ReportStatus.PENDING,
"check_status_url": f"/api/v1/reports/{report_id}",
}
@app.get("/api/v1/reports/{report_id}")
def get_report_status(report_id: str):
"""Check the status of a report."""
if report_id not in reports:
raise HTTPException(status_code=404, detail="Report not found")
return reports[report_id]
For production systems with heavy background processing, use Celery with Redis or RabbitMQ as the broker.
Middleware intercepts every request before it reaches your endpoints and every response before it reaches the client. It is the ideal place for cross-cutting concerns like logging, authentication, CORS, rate limiting, and request timing.
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
import time
import uuid
import logging
app = FastAPI()
logger = logging.getLogger(__name__)
class RequestTimingMiddleware(BaseHTTPMiddleware):
"""Add request timing headers to every response."""
async def dispatch(self, request: Request, call_next):
start_time = time.perf_counter()
response = await call_next(request)
duration = time.perf_counter() - start_time
response.headers["X-Process-Time"] = f"{duration:.4f}"
response.headers["X-Process-Time-Ms"] = f"{duration * 1000:.2f}"
return response
class RequestIDMiddleware(BaseHTTPMiddleware):
"""Add a unique request ID to every request/response."""
async def dispatch(self, request: Request, call_next):
# Use client-provided ID or generate one
request_id = request.headers.get(
"X-Request-ID", str(uuid.uuid4())
)
# Store in request state for use in endpoints
request.state.request_id = request_id
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
class RequestLoggingMiddleware(BaseHTTPMiddleware):
"""Log every request and response."""
async def dispatch(self, request: Request, call_next):
# Log request
logger.info(
f"Request: {request.method} {request.url.path} "
f"client={request.client.host if request.client else 'unknown'}"
)
start_time = time.perf_counter()
response = await call_next(request)
duration = time.perf_counter() - start_time
# Log response
logger.info(
f"Response: {request.method} {request.url.path} "
f"status={response.status_code} duration={duration:.4f}s"
)
return response
# Register middleware (order matters — last added runs first)
app.add_middleware(RequestTimingMiddleware)
app.add_middleware(RequestIDMiddleware)
app.add_middleware(RequestLoggingMiddleware)
Cross-Origin Resource Sharing (CORS) is essential when your API is consumed by web browsers from different domains:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
# Configure CORS
app.add_middleware(
CORSMiddleware,
# Allowed origins (use specific domains in production)
allow_origins=[
"http://localhost:3000", # React dev server
"http://localhost:5173", # Vite dev server
"https://myapp.example.com", # Production frontend
],
# Allow credentials (cookies, authorization headers)
allow_credentials=True,
# Allowed HTTP methods
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
# Allowed request headers
allow_headers=[
"Authorization",
"Content-Type",
"X-Request-ID",
"X-API-Version",
],
# Headers exposed to the browser
expose_headers=[
"X-Request-ID",
"X-Process-Time",
"X-Total-Count",
],
# Cache preflight requests for 1 hour
max_age=3600,
)
allow_origins=["*"] with allow_credentials=True in production. This would allow any website to make authenticated requests to your API. Always specify exact allowed origins.from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from collections import defaultdict
import time
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Simple in-memory rate limiter."""
def __init__(self, app, requests_per_minute: int = 60):
super().__init__(app)
self.requests_per_minute = requests_per_minute
self.requests: dict[str, list[float]] = defaultdict(list)
async def dispatch(self, request: Request, call_next):
# Get client identifier
client_ip = request.client.host if request.client else "unknown"
# Clean old entries
now = time.time()
window_start = now - 60
self.requests[client_ip] = [
t for t in self.requests[client_ip] if t > window_start
]
# Check rate limit
if len(self.requests[client_ip]) >= self.requests_per_minute:
return JSONResponse(
status_code=429,
content={
"error": "Rate limit exceeded",
"message": f"Maximum {self.requests_per_minute} requests per minute",
"retry_after": 60,
},
headers={
"Retry-After": "60",
"X-RateLimit-Limit": str(self.requests_per_minute),
"X-RateLimit-Remaining": "0",
},
)
# Record request
self.requests[client_ip].append(now)
remaining = self.requests_per_minute - len(self.requests[client_ip])
response = await call_next(request)
# Add rate limit headers
response.headers["X-RateLimit-Limit"] = str(self.requests_per_minute)
response.headers["X-RateLimit-Remaining"] = str(remaining)
response.headers["X-RateLimit-Reset"] = str(int(window_start + 60))
return response
app.add_middleware(RateLimitMiddleware, requests_per_minute=100)
For simple middleware, use the @app.middleware("http") decorator:
from fastapi import FastAPI, Request
app = FastAPI()
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
"""Add security headers to all responses."""
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
response.headers["Cache-Control"] = "no-store"
return response
@app.middleware("http")
async def strip_trailing_slashes(request: Request, call_next):
"""Normalize URLs by stripping trailing slashes."""
if request.url.path != "/" and request.url.path.endswith("/"):
# Reconstruct URL without trailing slash
from starlette.datastructures import URL
url = request.url.replace(path=request.url.path.rstrip("/"))
request._url = url
return await call_next(request)
FastAPI automatically generates OpenAPI (Swagger) documentation. Customizing it makes your API more discoverable and easier to integrate with. Well-documented APIs dramatically reduce integration time for consumers.
from fastapi import FastAPI
app = FastAPI(
title="Book Management API",
description="""
## Book Management REST API
A comprehensive API for managing books, authors, and reviews.
### Features
* **Books** — Full CRUD with filtering, pagination, and sorting
* **Authors** — Author management with book associations
* **Reviews** — User reviews with ratings and moderation
* **Reports** — Async report generation
### Authentication
All endpoints require a Bearer token in the Authorization header.
See the `/auth/token` endpoint for obtaining tokens.
""",
version="2.1.0",
terms_of_service="https://example.com/terms",
contact={
"name": "API Support",
"url": "https://example.com/support",
"email": "api@example.com",
},
license_info={
"name": "MIT",
"url": "https://opensource.org/licenses/MIT",
},
# Custom docs URLs
docs_url="/docs", # Swagger UI
redoc_url="/redoc", # ReDoc
openapi_url="/openapi.json",
)
from fastapi import FastAPI, APIRouter
# Define tag metadata for documentation
tags_metadata = [
{
"name": "books",
"description": "Operations for managing books in the catalog.",
},
{
"name": "authors",
"description": "Author management and book associations.",
},
{
"name": "reviews",
"description": "Book reviews and ratings by users.",
},
{
"name": "reports",
"description": "Generate and retrieve analytical reports.",
"externalDocs": {
"description": "Report format specification",
"url": "https://example.com/docs/reports",
},
},
{
"name": "admin",
"description": "Administrative operations. **Requires admin role.**",
},
]
app = FastAPI(
title="Book Management API",
openapi_tags=tags_metadata,
)
# Routers with tags
books_router = APIRouter(prefix="/api/v1/books", tags=["books"])
authors_router = APIRouter(prefix="/api/v1/authors", tags=["authors"])
reviews_router = APIRouter(prefix="/api/v1/reviews", tags=["reviews"])
admin_router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
@books_router.get(
"/",
summary="List all books",
description="Retrieve a paginated list of books with optional filtering.",
operation_id="listBooks",
)
def list_books():
return book_store.get_all()
@books_router.post(
"/",
summary="Create a new book",
description="Add a new book to the catalog. ISBN must be unique.",
operation_id="createBook",
response_description="The newly created book",
)
def create_book(book: BookCreate):
return book_store.create(book.model_dump())
app.include_router(books_router)
app.include_router(authors_router)
app.include_router(reviews_router)
app.include_router(admin_router)
from pydantic import BaseModel, Field, ConfigDict
class BookCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=300)
author: str = Field(..., min_length=1, max_length=200)
isbn: str = Field(..., pattern=r"^\d{13}$")
price: float = Field(..., gt=0, le=10000)
genre: str = Field(None, max_length=50)
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"title": "Clean Code",
"author": "Robert C. Martin",
"isbn": "9780132350884",
"price": 39.99,
"genre": "programming",
},
{
"title": "The Pragmatic Programmer",
"author": "David Thomas, Andrew Hunt",
"isbn": "9780135957059",
"price": 49.99,
"genre": "programming",
},
]
}
)
@books_router.post(
"/",
response_model=BookResponse,
status_code=201,
responses={
201: {
"description": "Book created successfully",
"content": {
"application/json": {
"example": {
"id": 1,
"title": "Clean Code",
"author": "Robert C. Martin",
"isbn": "9780132350884",
"price": 39.99,
"genre": "programming",
"status": "available",
"created_at": "2024-01-15T10:30:00",
}
}
},
},
409: {
"description": "Book with this ISBN already exists",
"content": {
"application/json": {
"example": {
"detail": "Book with ISBN 9780132350884 already exists"
}
}
},
},
},
)
def create_book(book: BookCreate):
return book_store.create(book.model_dump())
@app.get(
"/api/v1/books/search",
deprecated=True,
summary="[DEPRECATED] Search books",
description=(
"**Deprecated**: Use `GET /api/v2/books?search=query` instead. "
"This endpoint will be removed on 2025-06-01."
),
tags=["books"],
)
def search_books_v1(q: str):
"""Old search endpoint — marked as deprecated in docs."""
books = book_store.get_all()
return [b for b in books if q.lower() in b["title"].lower()]
# Hidden from OpenAPI docs but still accessible
@app.get("/internal/health", include_in_schema=False)
def health_check():
return {"status": "healthy"}
@app.get("/internal/metrics", include_in_schema=False)
def metrics():
return {
"total_books": book_store.count(),
"uptime_seconds": time.time() - start_time,
}
Let us bring everything together into a production-ready Book Management API. This project demonstrates all the concepts covered in this tutorial — CRUD operations, pagination, filtering, error handling, background tasks, middleware, and proper project structure.
book_api/ +-- main.py # Application entry point +-- config.py # Configuration settings +-- models.py # Pydantic schemas +-- store.py # Data store (in-memory for demo) +-- routers/ | +-- __init__.py | +-- books.py # Book endpoints | +-- authors.py # Author endpoints | +-- health.py # Health check endpoints +-- middleware/ | +-- __init__.py | +-- timing.py # Request timing | +-- logging_mw.py # Request logging +-- dependencies.py # Shared dependencies +-- exceptions.py # Custom exceptions +-- requirements.txt +-- Dockerfile
# config.py
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
app_name: str = "Book Management API"
app_version: str = "1.0.0"
debug: bool = False
api_prefix: str = "/api/v1"
# Pagination defaults
default_page_size: int = 20
max_page_size: int = 100
# Rate limiting
rate_limit_per_minute: int = 100
# CORS
allowed_origins: list[str] = ["http://localhost:3000"]
class Config:
env_file = ".env"
@lru_cache
def get_settings() -> Settings:
return Settings()
# exceptions.py
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from datetime import datetime
class AppException(Exception):
def __init__(self, message: str, status_code: int = 500):
self.message = message
self.status_code = status_code
class NotFoundException(AppException):
def __init__(self, resource: str, resource_id):
super().__init__(
message=f"{resource} with ID {resource_id} not found",
status_code=404,
)
class DuplicateException(AppException):
def __init__(self, resource: str, field: str, value: str):
super().__init__(
message=f"{resource} with {field}='{value}' already exists",
status_code=409,
)
class BadRequestException(AppException):
def __init__(self, message: str):
super().__init__(message=message, status_code=400)
def register_exception_handlers(app: FastAPI):
"""Register all custom exception handlers."""
@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException):
return JSONResponse(
status_code=exc.status_code,
content={
"success": False,
"error": type(exc).__name__,
"message": exc.message,
"timestamp": datetime.utcnow().isoformat(),
},
)
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
errors = []
for error in exc.errors():
field = " -> ".join(str(loc) for loc in error["loc"])
errors.append({"field": field, "message": error["msg"]})
return JSONResponse(
status_code=422,
content={
"success": False,
"error": "ValidationError",
"message": "Request validation failed",
"errors": errors,
},
)
# models.py
from pydantic import BaseModel, Field, ConfigDict, field_validator, computed_field
from typing import Optional, TypeVar, Generic
from datetime import datetime
from enum import Enum
from math import ceil
T = TypeVar("T")
# ---------- Enums ----------
class BookStatus(str, Enum):
AVAILABLE = "available"
CHECKED_OUT = "checked_out"
RESERVED = "reserved"
ARCHIVED = "archived"
class SortField(str, Enum):
TITLE = "title"
AUTHOR = "author"
PRICE = "price"
CREATED = "created_at"
YEAR = "published_year"
class SortOrder(str, Enum):
ASC = "asc"
DESC = "desc"
# ---------- Author Schemas ----------
class AuthorBase(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
bio: Optional[str] = Field(None, max_length=2000)
nationality: Optional[str] = Field(None, max_length=100)
class AuthorCreate(AuthorBase):
pass
class AuthorResponse(AuthorBase):
id: int
book_count: int = 0
created_at: datetime
model_config = ConfigDict(from_attributes=True)
# ---------- Book Schemas ----------
class BookBase(BaseModel):
title: str = Field(..., min_length=1, max_length=300)
author_name: str = Field(..., min_length=1, max_length=200)
isbn: str = Field(..., pattern=r"^\d{10}(\d{3})?$")
genre: Optional[str] = Field(None, max_length=50)
published_year: Optional[int] = Field(None, ge=1000, le=2030)
price: float = Field(..., gt=0, le=10000)
description: Optional[str] = Field(None, max_length=5000)
tags: list[str] = Field(default_factory=list)
@field_validator("tags")
@classmethod
def normalize_tags(cls, v):
return [tag.lower().strip() for tag in v if tag.strip()][:10]
class BookCreate(BookBase):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"title": "Clean Code",
"author_name": "Robert C. Martin",
"isbn": "9780132350884",
"genre": "programming",
"published_year": 2008,
"price": 39.99,
"description": "A handbook of agile software craftsmanship",
"tags": ["programming", "best-practices"],
}
]
}
)
class BookUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=300)
author_name: Optional[str] = Field(None, min_length=1, max_length=200)
isbn: Optional[str] = Field(None, pattern=r"^\d{10}(\d{3})?$")
genre: Optional[str] = Field(None, max_length=50)
published_year: Optional[int] = Field(None, ge=1000, le=2030)
price: Optional[float] = Field(None, gt=0, le=10000)
description: Optional[str] = Field(None, max_length=5000)
status: Optional[BookStatus] = None
tags: Optional[list[str]] = None
class BookResponse(BookBase):
id: int
status: BookStatus = BookStatus.AVAILABLE
created_at: datetime
updated_at: Optional[datetime] = None
@computed_field
@property
def is_recent(self) -> bool:
return (datetime.utcnow() - self.created_at).days < 30
model_config = ConfigDict(from_attributes=True)
class BookSummary(BaseModel):
id: int
title: str
author_name: str
price: float
genre: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
# ---------- Pagination ----------
class PaginatedResponse(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
per_page: int
total_pages: int
has_next: bool
has_prev: bool
# ---------- Bulk Operations ----------
class BulkDeleteRequest(BaseModel):
ids: list[int] = Field(..., min_length=1, max_length=100)
class BulkDeleteResponse(BaseModel):
deleted: list[int]
not_found: list[int]
# ---------- Reports ----------
class ReportRequest(BaseModel):
report_type: str = Field(..., pattern=r"^(inventory|sales|popular)$")
filters: dict = Field(default_factory=dict)
# store.py
from datetime import datetime
from typing import Optional
class InMemoryStore:
"""Generic in-memory data store."""
def __init__(self):
self._data: dict[int, dict] = {}
self._next_id: int = 1
def create(self, data: dict) -> dict:
item_id = self._next_id
self._next_id += 1
item = {
"id": item_id,
**data,
"created_at": datetime.utcnow(),
"updated_at": None,
}
self._data[item_id] = item
return item
def get(self, item_id: int) -> Optional[dict]:
return self._data.get(item_id)
def get_all(self) -> list[dict]:
return list(self._data.values())
def update(self, item_id: int, data: dict) -> Optional[dict]:
if item_id not in self._data:
return None
self._data[item_id].update(data)
self._data[item_id]["updated_at"] = datetime.utcnow()
return self._data[item_id]
def delete(self, item_id: int) -> bool:
if item_id not in self._data:
return False
del self._data[item_id]
return True
def exists(self, item_id: int) -> bool:
return item_id in self._data
def count(self) -> int:
return len(self._data)
def find_by(self, field: str, value) -> Optional[dict]:
for item in self._data.values():
if item.get(field) == value:
return item
return None
# Application stores
book_store = InMemoryStore()
author_store = InMemoryStore()
# Seed with sample data
sample_books = [
{
"title": "Clean Code",
"author_name": "Robert C. Martin",
"isbn": "9780132350884",
"genre": "programming",
"published_year": 2008,
"price": 39.99,
"description": "A handbook of agile software craftsmanship",
"tags": ["programming", "best-practices", "clean-code"],
"status": "available",
},
{
"title": "Design Patterns",
"author_name": "Gang of Four",
"isbn": "9780201633610",
"genre": "programming",
"published_year": 1994,
"price": 54.99,
"description": "Elements of Reusable Object-Oriented Software",
"tags": ["programming", "design-patterns", "oop"],
"status": "available",
},
{
"title": "The Pragmatic Programmer",
"author_name": "David Thomas",
"isbn": "9780135957059",
"genre": "programming",
"published_year": 2019,
"price": 49.99,
"description": "Your journey to mastery, 20th Anniversary Edition",
"tags": ["programming", "best-practices", "career"],
"status": "available",
},
{
"title": "Dune",
"author_name": "Frank Herbert",
"isbn": "9780441013593",
"genre": "science-fiction",
"published_year": 1965,
"price": 17.99,
"description": "A stunning blend of adventure and mysticism",
"tags": ["sci-fi", "classic", "space"],
"status": "available",
},
{
"title": "1984",
"author_name": "George Orwell",
"isbn": "9780451524935",
"genre": "dystopian",
"published_year": 1949,
"price": 12.99,
"description": "A dystopian social science fiction novel",
"tags": ["classic", "dystopian", "politics"],
"status": "available",
},
]
for book_data in sample_books:
book_store.create(book_data)
# dependencies.py
from fastapi import Query, Depends
from dataclasses import dataclass
from typing import Optional
from config import Settings, get_settings
from models import SortField, SortOrder
@dataclass
class PaginationParams:
page: int = Query(1, ge=1, description="Page number")
per_page: int = Query(20, ge=1, le=100, description="Items per page")
@property
def offset(self) -> int:
return (self.page - 1) * self.per_page
@property
def limit(self) -> int:
return self.per_page
@dataclass
class BookFilterParams:
search: Optional[str] = Query(None, min_length=2, description="Search title/author")
genre: Optional[str] = Query(None, description="Filter by genre")
author: Optional[str] = Query(None, description="Filter by author")
min_price: Optional[float] = Query(None, ge=0)
max_price: Optional[float] = Query(None, ge=0)
published_after: Optional[int] = Query(None, ge=1000)
published_before: Optional[int] = Query(None, le=2030)
status: Optional[str] = Query(None)
tags: Optional[list[str]] = Query(None)
def apply(self, items: list[dict]) -> list[dict]:
result = items
if self.search:
q = self.search.lower()
result = [
b for b in result
if q in b["title"].lower() or q in b["author_name"].lower()
]
if self.genre:
result = [b for b in result if b.get("genre", "").lower() == self.genre.lower()]
if self.author:
result = [b for b in result if self.author.lower() in b["author_name"].lower()]
if self.min_price is not None:
result = [b for b in result if b["price"] >= self.min_price]
if self.max_price is not None:
result = [b for b in result if b["price"] <= self.max_price]
if self.published_after is not None:
result = [
b for b in result
if b.get("published_year") and b["published_year"] >= self.published_after
]
if self.published_before is not None:
result = [
b for b in result
if b.get("published_year") and b["published_year"] <= self.published_before
]
if self.status:
result = [b for b in result if b.get("status") == self.status]
if self.tags:
tags_set = set(t.lower() for t in self.tags)
result = [
b for b in result
if tags_set.issubset(set(t.lower() for t in b.get("tags", [])))
]
return result
@dataclass
class SortParams:
sort_by: SortField = Query(SortField.CREATED, description="Sort field")
sort_order: SortOrder = Query(SortOrder.DESC, description="Sort order")
def apply(self, items: list[dict]) -> list[dict]:
reverse = self.sort_order == SortOrder.DESC
return sorted(
items,
key=lambda x: x.get(self.sort_by.value, "") or "",
reverse=reverse,
)
# routers/books.py
from fastapi import APIRouter, Depends, Response, BackgroundTasks, status
from math import ceil
from typing import Optional
from models import (
BookCreate, BookUpdate, BookResponse, BookSummary,
PaginatedResponse, BulkDeleteRequest, BulkDeleteResponse,
)
from store import book_store
from dependencies import PaginationParams, BookFilterParams, SortParams
from exceptions import NotFoundException, DuplicateException, BadRequestException
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/books", tags=["books"])
def log_action(action: str, book_id: int, details: str = ""):
"""Background task for logging book actions."""
logger.info(f"BOOK_ACTION: {action} id={book_id} {details}")
@router.get(
"/",
response_model=PaginatedResponse[BookSummary],
summary="List books",
description="Retrieve a paginated, filterable, sortable list of books.",
)
def list_books(
filters: BookFilterParams = Depends(),
sorting: SortParams = Depends(),
pagination: PaginationParams = Depends(),
):
books = book_store.get_all()
books = filters.apply(books)
books = sorting.apply(books)
total = len(books)
items = books[pagination.offset : pagination.offset + pagination.limit]
total_pages = ceil(total / pagination.per_page) if pagination.per_page else 0
return {
"items": items,
"total": total,
"page": pagination.page,
"per_page": pagination.per_page,
"total_pages": total_pages,
"has_next": pagination.page < total_pages,
"has_prev": pagination.page > 1,
}
@router.get(
"/{book_id}",
response_model=BookResponse,
summary="Get a book",
responses={404: {"description": "Book not found"}},
)
def get_book(book_id: int):
book = book_store.get(book_id)
if not book:
raise NotFoundException("Book", book_id)
return book
@router.post(
"/",
response_model=BookResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a book",
)
def create_book(
book: BookCreate,
background_tasks: BackgroundTasks,
):
existing = book_store.find_by("isbn", book.isbn)
if existing:
raise DuplicateException("Book", "isbn", book.isbn)
created = book_store.create({**book.model_dump(), "status": "available"})
background_tasks.add_task(log_action, "CREATE", created["id"], book.title)
return created
@router.put(
"/{book_id}",
response_model=BookResponse,
summary="Replace a book",
)
def replace_book(
book_id: int,
book: BookCreate,
background_tasks: BackgroundTasks,
):
if not book_store.exists(book_id):
raise NotFoundException("Book", book_id)
existing = book_store.find_by("isbn", book.isbn)
if existing and existing["id"] != book_id:
raise DuplicateException("Book", "isbn", book.isbn)
updated = book_store.update(book_id, book.model_dump())
background_tasks.add_task(log_action, "REPLACE", book_id)
return updated
@router.patch(
"/{book_id}",
response_model=BookResponse,
summary="Update a book partially",
)
def update_book(
book_id: int,
book: BookUpdate,
background_tasks: BackgroundTasks,
):
if not book_store.exists(book_id):
raise NotFoundException("Book", book_id)
update_data = book.model_dump(exclude_unset=True)
if not update_data:
raise BadRequestException("No fields provided for update")
if "isbn" in update_data:
existing = book_store.find_by("isbn", update_data["isbn"])
if existing and existing["id"] != book_id:
raise DuplicateException("Book", "isbn", update_data["isbn"])
updated = book_store.update(book_id, update_data)
background_tasks.add_task(log_action, "UPDATE", book_id, str(update_data.keys()))
return updated
@router.delete(
"/{book_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a book",
)
def delete_book(book_id: int, background_tasks: BackgroundTasks):
if not book_store.exists(book_id):
raise NotFoundException("Book", book_id)
book_store.delete(book_id)
background_tasks.add_task(log_action, "DELETE", book_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post(
"/bulk-delete",
response_model=BulkDeleteResponse,
summary="Delete multiple books",
)
def bulk_delete(request: BulkDeleteRequest, background_tasks: BackgroundTasks):
deleted = []
not_found = []
for book_id in request.ids:
if book_store.delete(book_id):
deleted.append(book_id)
else:
not_found.append(book_id)
background_tasks.add_task(
log_action, "BULK_DELETE", 0, f"deleted={deleted}"
)
return {"deleted": deleted, "not_found": not_found}
# main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from contextlib import asynccontextmanager
import time
import uuid
import logging
from config import get_settings
from exceptions import register_exception_handlers
from routers.books import router as books_router
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
# Lifespan event handler
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup and shutdown events."""
settings = get_settings()
logger.info(f"Starting {settings.app_name} v{settings.app_version}")
logger.info(f"Debug mode: {settings.debug}")
yield # Application runs here
logger.info("Shutting down application")
# Create application
settings = get_settings()
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
description="A comprehensive Book Management REST API built with FastAPI.",
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc",
)
# --- Middleware ---
class TimingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
start = time.perf_counter()
response = await call_next(request)
duration = time.perf_counter() - start
response.headers["X-Process-Time"] = f"{duration:.4f}"
return response
class RequestIDMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
request.state.request_id = request_id
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
app.add_middleware(TimingMiddleware)
app.add_middleware(RequestIDMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- Exception Handlers ---
register_exception_handlers(app)
# --- Routers ---
app.include_router(books_router)
# --- Health Check ---
@app.get("/health", tags=["health"])
def health_check():
return {
"status": "healthy",
"version": settings.app_version,
"timestamp": time.time(),
}
@app.get("/", tags=["root"])
def root():
return {
"name": settings.app_name,
"version": settings.app_version,
"docs": "/docs",
"redoc": "/redoc",
}
# Run with: uvicorn main:app --reload
# Dockerfile FROM python:3.12-slim WORKDIR /app # Install dependencies COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Copy application code COPY . . # Create non-root user RUN adduser --disabled-password --gecos "" appuser USER appuser # Expose port EXPOSE 8000 # Run with uvicorn CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# requirements.txt fastapi==0.115.0 uvicorn[standard]==0.30.0 pydantic[email]==2.9.0 pydantic-settings==2.5.0 python-multipart==0.0.9
Once the application is running (uvicorn main:app --reload), test it with curl:
# List all books (paginated)
curl -s http://localhost:8000/api/v1/books | python -m json.tool
# Get a specific book
curl -s http://localhost:8000/api/v1/books/1 | python -m json.tool
# Create a new book
curl -s -X POST http://localhost:8000/api/v1/books -H "Content-Type: application/json" -d '{
"title": "Fluent Python",
"author_name": "Luciano Ramalho",
"isbn": "9781492056355",
"genre": "programming",
"published_year": 2022,
"price": 59.99,
"description": "Clear, concise, and effective programming",
"tags": ["python", "advanced"]
}' | python -m json.tool
# Search books
curl -s "http://localhost:8000/api/v1/books?search=clean&sort_by=price&sort_order=asc" | python -m json.tool
# Filter by genre and price range
curl -s "http://localhost:8000/api/v1/books?genre=programming&min_price=30&max_price=50" | python -m json.tool
# Partial update
curl -s -X PATCH http://localhost:8000/api/v1/books/1 -H "Content-Type: application/json" -d '{"price": 34.99, "status": "reserved"}' | python -m json.tool
# Delete a book
curl -s -X DELETE http://localhost:8000/api/v1/books/4
# Bulk delete
curl -s -X POST http://localhost:8000/api/v1/books/bulk-delete -H "Content-Type: application/json" -d '{"ids": [3, 4, 99]}' | python -m json.tool
# Health check
curl -s http://localhost:8000/health | python -m json.tool
Visit http://localhost:8000/docs to see the interactive Swagger UI documentation, or http://localhost:8000/redoc for the ReDoc view.
Building a production-quality REST API with FastAPI requires understanding not just the framework, but REST architecture itself. Here is a summary of the most important lessons from this tutorial:
/books not /getBooks/api/v1/ than to add it laterresponse_model to filter passwords, API keys, and internal fieldsexclude_unset=True for PATCH endpoints to distinguish between “not provided” and “set to null”responses parameter?genres=fiction&genres=science) for flexible queryingallow_origins=["*"] with credentials in production| Operation | Method | Path | Status Code | Returns |
|---|---|---|---|---|
| List (paginated) | GET | /api/v1/books |
200 | Paginated list |
| Get single | GET | /api/v1/books/{id} |
200 | Single resource |
| Create | POST | /api/v1/books |
201 | Created resource |
| Full replace | PUT | /api/v1/books/{id} |
200 | Updated resource |
| Partial update | PATCH | /api/v1/books/{id} |
200 | Updated resource |
| Delete | DELETE | /api/v1/books/{id} |
204 | No content |
| Bulk create | POST | /api/v1/books/bulk |
201 | Created list + errors |
| Bulk delete | POST | /api/v1/books/bulk-delete |
200 | Deleted IDs + not found |
| Search | GET | /api/v1/books?search=term |
200 | Filtered list |
| Async task | POST | /api/v1/reports |
202 | Task ID + status URL |
With these patterns and the complete Book Management API project as a reference, you have everything you need to build robust, well-structured REST APIs with FastAPI. In the next tutorials, we will cover database integration with SQLAlchemy, testing strategies, and authentication and authorization.
FastAPI provides one of the most intuitive and powerful routing systems in any Python web framework. Built on top of Starlette and enhanced with Python type hints, FastAPI’s routing lets you define endpoints that automatically validate inputs, generate documentation, and handle serialization — all with minimal boilerplate. In this tutorial, we will explore every aspect of routing and request handling in FastAPI, from basic path operations to advanced patterns like dependency injection and API routers.
pip install fastapi uvicorn python-multipart aiofiles
python-multipart is needed for form data and file uploads. aiofiles is needed for FileResponse.
In FastAPI, a route (also called a path operation) is the combination of an HTTP method and a URL path that maps to a Python function. You define routes using decorators on your FastAPI application instance.
Every FastAPI application starts with creating an instance of the FastAPI class:
from fastapi import FastAPI
app = FastAPI(
title="My API",
description="A demonstration of FastAPI routing",
version="1.0.0"
)
FastAPI provides decorators for every standard HTTP method. Each decorator takes a path string and registers the decorated function as the handler for that path and method combination:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
"""Handle GET requests to the root path."""
return {"message": "Hello, World!"}
@app.post("/items")
def create_item():
"""Handle POST requests to create an item."""
return {"message": "Item created"}
@app.put("/items/{item_id}")
def update_item(item_id: int):
"""Handle PUT requests to update an item."""
return {"message": f"Item {item_id} updated"}
@app.patch("/items/{item_id}")
def partial_update_item(item_id: int):
"""Handle PATCH requests to partially update an item."""
return {"message": f"Item {item_id} partially updated"}
@app.delete("/items/{item_id}")
def delete_item(item_id: int):
"""Handle DELETE requests to delete an item."""
return {"message": f"Item {item_id} deleted"}
@app.options("/items")
def options_items():
"""Handle OPTIONS requests (CORS preflight)."""
return {"allowed_methods": ["GET", "POST", "PUT", "DELETE"]}
@app.head("/items")
def head_items():
"""Handle HEAD requests (like GET but no body)."""
return None
FastAPI supports both synchronous and asynchronous handler functions. Use async def when your handler performs I/O-bound operations (database queries, HTTP calls, file operations):
import httpx
from fastapi import FastAPI
app = FastAPI()
# Synchronous handler — FastAPI runs this in a thread pool
@app.get("/sync")
def sync_handler():
return {"type": "synchronous"}
# Asynchronous handler — runs directly on the event loop
@app.get("/async")
async def async_handler():
return {"type": "asynchronous"}
# Real-world async example: calling an external API
@app.get("/external-data")
async def get_external_data():
async with httpx.AsyncClient() as client:
response = await client.get("https://jsonplaceholder.typicode.com/posts/1")
return response.json()
def (not async def), FastAPI runs it in a thread pool to avoid blocking the event loop. If you use async def, the function runs directly on the event loop — so never use blocking operations (like time.sleep() or synchronous database calls) inside an async def handler.FastAPI evaluates routes in the order they are defined. This matters when you have routes with overlapping patterns:
from fastapi import FastAPI
app = FastAPI()
# This must come BEFORE "/users/{user_id}" to avoid "me" being
# interpreted as a user_id
@app.get("/users/me")
def read_current_user():
return {"user": "current_user"}
@app.get("/users/{user_id}")
def read_user(user_id: int):
return {"user_id": user_id}
If you reversed the order, a request to /users/me would match the /users/{user_id} route first and fail because "me" cannot be converted to an integer.
You can apply multiple decorators to the same function if you want one handler to respond to multiple HTTP methods or paths:
from fastapi import FastAPI, APIRouter
app = FastAPI()
# Using api_route for multiple methods
@app.api_route("/health", methods=["GET", "HEAD"])
def health_check():
return {"status": "healthy"}
# Development with auto-reload uvicorn main:app --reload --host 0.0.0.0 --port 8000 # The interactive docs are available at: # http://localhost:8000/docs (Swagger UI) # http://localhost:8000/redoc (ReDoc)
Path parameters allow you to capture dynamic segments of the URL path. FastAPI uses Python type annotations to automatically parse, validate, and document path parameters.
from fastapi import FastAPI
app = FastAPI()
@app.get("/users/{user_id}")
def get_user(user_id: int):
"""The user_id is automatically parsed as an integer."""
return {"user_id": user_id}
@app.get("/files/{file_path:path}")
def get_file(file_path: str):
"""The :path converter captures the rest of the URL including slashes.
Example: /files/home/user/document.txt
"""
return {"file_path": file_path}
If a client sends GET /users/abc, FastAPI automatically returns a 422 validation error because "abc" cannot be converted to an integer:
# Automatic error response for invalid path parameter:
{
"detail": [
{
"type": "int_parsing",
"loc": ["path", "user_id"],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"input": "abc"
}
]
}
FastAPI supports various Python types for path parameters. The type annotation determines how the value is parsed and validated:
| Type | Example | Valid Input | Invalid Input |
|---|---|---|---|
int |
item_id: int |
/items/42 |
/items/abc |
float |
price: float |
/price/19.99 |
/price/abc |
str |
name: str |
/name/alice |
(always valid) |
UUID |
id: UUID |
/id/550e8400-... |
/id/not-a-uuid |
Enum |
color: Color |
/color/red |
/color/purple |
You can restrict path parameters to a set of predefined values using Python Enums:
from enum import Enum
from fastapi import FastAPI
app = FastAPI()
class ModelName(str, Enum):
"""Available ML model names."""
alexnet = "alexnet"
resnet = "resnet"
lenet = "lenet"
@app.get("/models/{model_name}")
def get_model(model_name: ModelName):
messages = {
ModelName.alexnet: "Deep Learning FTW!",
ModelName.resnet: "LeCun would be proud",
ModelName.lenet: "Have some residuals",
}
return {
"model_name": model_name.value,
"message": messages[model_name]
}
The Path() function provides additional validation and metadata for path parameters:
from fastapi import FastAPI, Path
app = FastAPI()
@app.get("/items/{item_id}")
def read_item(
item_id: int = Path(
...,
title="Item ID",
description="The unique identifier of the item to retrieve",
ge=1, # greater than or equal to 1
le=10000, # less than or equal to 10000
examples=[42, 100, 999]
)
):
return {"item_id": item_id}
@app.get("/products/{product_code}")
def read_product(
product_code: str = Path(
...,
title="Product Code",
description="Alphanumeric product code",
min_length=3,
max_length=10,
pattern="^[A-Z]{2,4}-[0-9]{3,6}$"
)
):
"""Accepts product codes like AB-123, PROD-456789."""
return {"product_code": product_code}
... (Ellipsis) in Path(...) indicates that the parameter is required. For path parameters, this is always the case since they are part of the URL path itself.from fastapi import FastAPI, Path
app = FastAPI()
@app.get("/organizations/{org_id}/teams/{team_id}/members/{member_id}")
def get_team_member(
org_id: int = Path(..., ge=1, description="Organization ID"),
team_id: int = Path(..., ge=1, description="Team ID"),
member_id: int = Path(..., ge=1, description="Member ID"),
):
return {
"org_id": org_id,
"team_id": team_id,
"member_id": member_id
}
# UUID path parameters
from uuid import UUID
@app.get("/orders/{order_id}/items/{item_id}")
def get_order_item(order_id: UUID, item_id: UUID):
return {
"order_id": str(order_id),
"item_id": str(item_id)
}
Query parameters are the key-value pairs that appear after the ? in a URL (e.g., /items?skip=0&limit=10). In FastAPI, any function parameter that is not a path parameter is automatically treated as a query parameter.
from fastapi import FastAPI
app = FastAPI()
# All three parameters are query parameters
# GET /items?skip=0&limit=10
@app.get("/items")
def list_items(skip: int = 0, limit: int = 10):
fake_items = [{"id": i, "name": f"Item {i}"} for i in range(100)]
return fake_items[skip : skip + limit]
# Mix of path and query parameters
# GET /users/42/items?skip=0&limit=5
@app.get("/users/{user_id}/items")
def get_user_items(user_id: int, skip: int = 0, limit: int = 5):
return {
"user_id": user_id,
"skip": skip,
"limit": limit
}
from typing import Optional
from fastapi import FastAPI
app = FastAPI()
@app.get("/search")
def search(
q: str, # Required (no default)
category: str = "all", # Optional with default "all"
page: int = 1, # Optional with default 1
include_archived: bool = False, # Optional with default False
sort_by: Optional[str] = None, # Optional, defaults to None
):
"""
GET /search?q=python (only required param)
GET /search?q=python&category=books&page=2 (with optional params)
GET /search?q=python&include_archived=true (boolean query param)
"""
results = {
"query": q,
"category": category,
"page": page,
"include_archived": include_archived,
}
if sort_by:
results["sort_by"] = sort_by
return results
True: true, True, 1, yes, on. And these as False: false, False, 0, no, off.The Query() function provides extensive validation options for query parameters:
from typing import Optional, List
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/products")
def search_products(
# Required with validation
q: str = Query(
...,
min_length=2,
max_length=100,
title="Search Query",
description="The search term to find products",
examples=["laptop", "wireless mouse"]
),
# Optional with numeric validation
min_price: Optional[float] = Query(
None,
ge=0,
description="Minimum price filter"
),
max_price: Optional[float] = Query(
None,
ge=0,
le=100000,
description="Maximum price filter"
),
# Pattern validation with regex
sku: Optional[str] = Query(
None,
pattern="^[A-Z]{3}-[0-9]{4}$",
description="Product SKU in format ABC-1234"
),
# Pagination with defaults and limits
page: int = Query(1, ge=1, le=1000, description="Page number"),
per_page: int = Query(20, ge=1, le=100, description="Items per page"),
):
return {
"query": q,
"min_price": min_price,
"max_price": max_price,
"sku": sku,
"page": page,
"per_page": per_page,
}
from typing import List, Optional
from fastapi import FastAPI, Query
app = FastAPI()
# GET /items?tag=python&tag=fastapi&tag=tutorial
@app.get("/items")
def filter_items(
tag: List[str] = Query(
default=[],
title="Tags",
description="Filter items by one or more tags",
),
status: List[str] = Query(
default=["active"],
description="Filter by status. Defaults to active only.",
),
):
return {
"tags": tag,
"statuses": status,
}
# Enum-based list validation
from enum import Enum
class SortField(str, Enum):
name = "name"
price = "price"
created_at = "created_at"
rating = "rating"
@app.get("/catalog")
def get_catalog(
sort_by: List[SortField] = Query(
default=[SortField.created_at],
description="Sort by one or more fields"
),
):
return {"sort_by": [s.value for s in sort_by]}
from typing import Optional
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/items")
def read_items(
# Current parameter
q: Optional[str] = Query(None, description="Search query"),
# Deprecated parameter — still works but marked in docs
search: Optional[str] = Query(
None,
deprecated=True,
description="Deprecated. Use 'q' instead."
),
):
query = q or search
return {"query": query}
from fastapi import FastAPI, Query
app = FastAPI()
# When the query param name isn't a valid Python identifier
# GET /items?item-query=something
@app.get("/items")
def read_items(
q: str = Query(
...,
alias="item-query",
description="The query string uses a hyphen in the URL"
)
):
return {"query": q}
HTTP headers carry metadata about the request. FastAPI provides the Header() function to extract and validate header values with full type support.
from typing import Optional
from fastapi import FastAPI, Header
app = FastAPI()
@app.get("/items")
def read_items(
user_agent: Optional[str] = Header(None),
accept_language: Optional[str] = Header(None),
x_request_id: Optional[str] = Header(None),
):
"""FastAPI automatically converts header names:
- user_agent matches the 'User-Agent' header
- accept_language matches the 'Accept-Language' header
- x_request_id matches the 'X-Request-Id' header
Underscores in the Python parameter become hyphens in the HTTP header.
"""
return {
"user_agent": user_agent,
"accept_language": accept_language,
"x_request_id": x_request_id,
}
snake_case parameter names to HTTP-style Hyphen-Case header names. The parameter x_custom_header maps to the HTTP header X-Custom-Header. You can disable this with Header(..., convert_underscores=False).from fastapi import FastAPI, Header, HTTPException
app = FastAPI()
@app.get("/secure-data")
def get_secure_data(
x_api_key: str = Header(
...,
description="API key for authentication",
min_length=20,
max_length=100,
),
x_api_version: str = Header(
"v1",
description="API version",
pattern="^v[0-9]+$"
),
):
# Validate API key
if x_api_key != "my-secret-key-that-is-long-enough":
raise HTTPException(status_code=403, detail="Invalid API key")
return {"data": "sensitive information", "api_version": x_api_version}
from typing import List, Optional
from fastapi import FastAPI, Header
app = FastAPI()
# Some headers can appear multiple times (e.g., X-Forwarded-For)
@app.get("/check-proxies")
def check_proxies(
x_forwarded_for: Optional[List[str]] = Header(None)
):
return {"proxies": x_forwarded_for}
from typing import Optional
from fastapi import FastAPI, Header, HTTPException, Depends
app = FastAPI()
# Pattern: Extract and validate Authorization header
def get_token_from_header(
authorization: str = Header(..., description="Bearer token")
) -> str:
"""Extract token from Authorization header."""
if not authorization.startswith("Bearer "):
raise HTTPException(
status_code=401,
detail="Authorization header must start with 'Bearer '"
)
return authorization[7:] # Strip "Bearer " prefix
@app.get("/profile")
def get_profile(token: str = Depends(get_token_from_header)):
return {"token": token, "user": "authenticated_user"}
# Pattern: Content negotiation
@app.get("/data")
def get_data(accept: Optional[str] = Header("application/json")):
if "text/html" in accept:
return {"format": "Would return HTML"}
elif "application/xml" in accept:
return {"format": "Would return XML"}
return {"format": "JSON", "data": [1, 2, 3]}
# Pattern: Rate limiting headers
@app.get("/rate-limited")
def rate_limited_endpoint(
x_client_id: str = Header(..., description="Client identifier for rate limiting"),
):
return {
"client_id": x_client_id,
"message": "Request processed"
}
For POST, PUT, and PATCH requests, clients typically send data in the request body. FastAPI uses Pydantic models to define, validate, and document request body schemas.
from typing import Optional
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
class ItemCreate(BaseModel):
"""Schema for creating a new item."""
name: str = Field(
...,
min_length=1,
max_length=100,
description="The name of the item",
examples=["Widget Pro"]
)
description: Optional[str] = Field(
None,
max_length=1000,
description="Optional item description"
)
price: float = Field(
...,
gt=0,
le=1000000,
description="Price in USD"
)
tax: Optional[float] = Field(
None,
ge=0,
le=100,
description="Tax percentage"
)
tags: list[str] = Field(
default_factory=list,
max_length=10,
description="List of tags"
)
@app.post("/items")
def create_item(item: ItemCreate):
"""
Send a JSON body:
{
"name": "Widget Pro",
"price": 29.99,
"tax": 8.5,
"tags": ["electronics", "gadget"]
}
"""
item_dict = item.model_dump()
if item.tax:
price_with_tax = item.price + (item.price * item.tax / 100)
item_dict["price_with_tax"] = round(price_with_tax, 2)
return item_dict
from typing import Optional, List
from datetime import datetime
from fastapi import FastAPI
from pydantic import BaseModel, Field, EmailStr
app = FastAPI()
class Address(BaseModel):
street: str = Field(..., min_length=1)
city: str = Field(..., min_length=1)
state: str = Field(..., min_length=2, max_length=2)
zip_code: str = Field(..., pattern="^[0-9]{5}(-[0-9]{4})?$")
country: str = Field(default="US")
class ContactInfo(BaseModel):
email: str = Field(..., description="Contact email")
phone: Optional[str] = Field(None, pattern="^\+?[0-9\-\s()]+$")
class OrderItem(BaseModel):
product_id: int = Field(..., ge=1)
quantity: int = Field(..., ge=1, le=1000)
unit_price: float = Field(..., gt=0)
class OrderCreate(BaseModel):
customer_name: str = Field(..., min_length=1, max_length=200)
contact: ContactInfo
shipping_address: Address
billing_address: Optional[Address] = None
items: List[OrderItem] = Field(..., min_length=1)
notes: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"customer_name": "Jane Doe",
"contact": {
"email": "jane@example.com",
"phone": "+1-555-0100"
},
"shipping_address": {
"street": "123 Main St",
"city": "Springfield",
"state": "IL",
"zip_code": "62701"
},
"items": [
{"product_id": 1, "quantity": 2, "unit_price": 29.99}
],
"notes": "Leave at door"
}
}
@app.post("/orders")
def create_order(order: OrderCreate):
total = sum(item.quantity * item.unit_price for item in order.items)
billing = order.billing_address or order.shipping_address
return {
"order_id": 12345,
"customer": order.customer_name,
"total": round(total, 2),
"item_count": len(order.items),
"billing_city": billing.city,
"created_at": datetime.utcnow().isoformat()
}
from fastapi import FastAPI, Body
from pydantic import BaseModel, Field
app = FastAPI()
class Item(BaseModel):
name: str
price: float
class User(BaseModel):
username: str
email: str
@app.put("/items/{item_id}")
def update_item(
item_id: int,
item: Item,
user: User,
importance: int = Body(
...,
ge=1,
le=5,
description="Priority level of the update"
),
note: str = Body(
None,
max_length=500,
description="Optional note about the update"
),
):
"""
Expected JSON body:
{
"item": {"name": "Widget", "price": 9.99},
"user": {"username": "john", "email": "john@example.com"},
"importance": 3,
"note": "Updated pricing"
}
"""
return {
"item_id": item_id,
"item": item.model_dump(),
"user": user.model_dump(),
"importance": importance,
"note": note
}
from fastapi import FastAPI, Body
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
price: float
# Without embed=True: {"name": "Widget", "price": 9.99}
# With embed=True: {"item": {"name": "Widget", "price": 9.99}}
@app.post("/items")
def create_item(item: Item = Body(..., embed=True)):
return item
from fastapi import FastAPI, Request
app = FastAPI()
@app.post("/webhook")
async def receive_webhook(request: Request):
"""Access raw request body for webhook signature verification."""
raw_body = await request.body()
json_body = await request.json()
# Useful for webhook signature verification
content_type = request.headers.get("content-type", "")
return {
"content_type": content_type,
"body_size": len(raw_body),
"parsed": json_body
}
FastAPI provides the Cookie() function to extract cookies from incoming requests, with the same validation and documentation features as other parameter types.
from typing import Optional
from fastapi import FastAPI, Cookie
app = FastAPI()
@app.get("/items")
def read_items(
session_id: Optional[str] = Cookie(None, description="Session cookie"),
tracking_id: Optional[str] = Cookie(None, description="Analytics tracking ID"),
preferences: Optional[str] = Cookie(None, description="User preferences JSON"),
):
return {
"session_id": session_id,
"tracking_id": tracking_id,
"preferences": preferences,
}
# Real-world example: session-based user identification
@app.get("/dashboard")
def get_dashboard(
session_token: str = Cookie(
...,
min_length=32,
max_length=128,
description="Required session token"
),
):
# In production, you would validate this against a session store
return {"session": session_token, "message": "Welcome to dashboard"}
-b flag:
curl -b "session_id=abc123;tracking_id=xyz789" http://localhost:8000/items
For HTML forms and file uploads, FastAPI provides the Form() and File()/UploadFile types. These require the python-multipart package.
from fastapi import FastAPI, Form
app = FastAPI()
# Standard HTML form submission
@app.post("/login")
def login(
username: str = Form(
...,
min_length=3,
max_length=50,
description="Username"
),
password: str = Form(
...,
min_length=8,
description="Password"
),
remember_me: bool = Form(False),
):
"""
Handles application/x-www-form-urlencoded data.
Test with:
curl -X POST http://localhost:8000/login \
-d "username=admin&password=secret123&remember_me=true"
"""
return {
"username": username,
"remember_me": remember_me,
"message": "Login successful"
}
# OAuth2 token endpoint (real-world pattern)
@app.post("/token")
def get_token(
grant_type: str = Form(..., pattern="^(password|refresh_token|client_credentials)$"),
username: str = Form(None),
password: str = Form(None),
refresh_token: str = Form(None),
scope: str = Form(""),
):
if grant_type == "password":
if not username or not password:
return {"error": "username and password required for password grant"}
return {
"access_token": "fake-jwt-token",
"token_type": "bearer",
"scope": scope
}
from typing import List
from fastapi import FastAPI, File, UploadFile, HTTPException, Form
app = FastAPI()
# Simple file upload
@app.post("/upload")
async def upload_file(file: UploadFile = File(..., description="File to upload")):
"""
UploadFile provides:
- file.filename: Original filename
- file.content_type: MIME type (e.g., "image/png")
- file.size: File size in bytes
- file.read(): Read file contents (async)
- file.seek(0): Reset file position
- file.close(): Close the file
"""
contents = await file.read()
return {
"filename": file.filename,
"content_type": file.content_type,
"size": len(contents),
}
# File upload with validation
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
MAX_SIZE = 5 * 1024 * 1024 # 5MB
@app.post("/upload-image")
async def upload_image(
image: UploadFile = File(..., description="Image file (JPEG, PNG, GIF, WebP)"),
alt_text: str = Form("", max_length=200),
):
# Validate content type
if image.content_type not in ALLOWED_TYPES:
raise HTTPException(
status_code=400,
detail=f"Invalid file type: {image.content_type}. Allowed: {ALLOWED_TYPES}"
)
# Read and validate size
contents = await image.read()
if len(contents) > MAX_SIZE:
raise HTTPException(
status_code=400,
detail=f"File too large: {len(contents)} bytes. Max: {MAX_SIZE} bytes"
)
# In production, save to disk or cloud storage
return {
"filename": image.filename,
"content_type": image.content_type,
"size": len(contents),
"alt_text": alt_text,
}
# Multiple file upload
@app.post("/upload-multiple")
async def upload_multiple_files(
files: List[UploadFile] = File(..., description="Multiple files to upload"),
):
results = []
for f in files:
contents = await f.read()
results.append({
"filename": f.filename,
"content_type": f.content_type,
"size": len(contents),
})
return {"files_uploaded": len(results), "files": results}
-F for multipart form data:
# Single file curl -X POST http://localhost:8000/upload -F "file=@photo.jpg" # Multiple files curl -X POST http://localhost:8000/upload-multiple -F "files=@photo1.jpg" -F "files=@photo2.png" # File with form data curl -X POST http://localhost:8000/upload-image -F "image=@photo.jpg" -F "alt_text=My vacation photo"
While FastAPI defaults to returning JSON responses, it supports multiple response types for different use cases.
from fastapi import FastAPI
from fastapi.responses import JSONResponse
app = FastAPI()
# Default behavior — dict is automatically serialized to JSON
@app.get("/items")
def get_items():
return {"items": [{"id": 1, "name": "Widget"}]}
# Explicit JSONResponse with custom status code and headers
@app.post("/items")
def create_item():
return JSONResponse(
content={"id": 1, "name": "Widget", "message": "Created"},
status_code=201,
headers={"X-Custom-Header": "created"}
)
# Custom JSON encoder for special types
import json
from datetime import datetime, date
from decimal import Decimal
class CustomEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if isinstance(obj, Decimal):
return float(obj)
return super().default(obj)
@app.get("/financial-report")
def financial_report():
data = {
"total": Decimal("1234567.89"),
"generated_at": datetime.utcnow(),
}
return JSONResponse(
content=json.loads(json.dumps(data, cls=CustomEncoder))
)
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
app = FastAPI()
@app.get("/page", response_class=HTMLResponse)
def get_page():
return """
<html>
<head><title>FastAPI HTML</title></head>
<body>
<h1>Hello from FastAPI!</h1>
<p>This is an HTML response.</p>
</body>
</html>
"""
# Dynamic HTML with data
@app.get("/users/{user_id}/profile", response_class=HTMLResponse)
def user_profile(user_id: int):
return f"""
<html>
<body>
<h1>User Profile</h1>
<p>User ID: {user_id}</p>
</body>
</html>
"""
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
app = FastAPI()
@app.get("/old-page")
def old_page():
"""301 permanent redirect."""
return RedirectResponse(url="/new-page", status_code=301)
@app.get("/new-page")
def new_page():
return {"message": "This is the new page"}
@app.post("/submit-form")
def submit_form():
"""303 See Other — redirect after POST (PRG pattern)."""
# Process form data...
return RedirectResponse(url="/success", status_code=303)
@app.get("/external")
def external_redirect():
"""Redirect to an external URL."""
return RedirectResponse(url="https://fastapi.tiangolo.com")
import asyncio
from typing import AsyncGenerator
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
# Stream a large CSV file
def generate_csv():
"""Generator that yields CSV rows one at a time."""
yield "id,name,email\n"
for i in range(1, 10001):
yield f"{i},user_{i},user_{i}@example.com\n"
@app.get("/export/csv")
def export_csv():
return StreamingResponse(
generate_csv(),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=users.csv"}
)
# Server-Sent Events (SSE) for real-time updates
async def event_stream() -> AsyncGenerator[str, None]:
"""Generates Server-Sent Events."""
for i in range(10):
await asyncio.sleep(1)
yield f"data: {{"count": {i}, "message": "Update {i}"}}\n\n"
yield "data: {"message": "Stream complete"}\n\n"
@app.get("/events")
async def get_events():
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
}
)
import os
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
app = FastAPI()
UPLOAD_DIR = "/tmp/uploads"
@app.get("/download/{filename}")
def download_file(filename: str):
file_path = os.path.join(UPLOAD_DIR, filename)
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(
path=file_path,
filename=filename,
media_type="application/octet-stream",
)
# Inline display (e.g., images)
@app.get("/images/{image_name}")
def get_image(image_name: str):
file_path = os.path.join(UPLOAD_DIR, "images", image_name)
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="Image not found")
return FileResponse(
path=file_path,
media_type="image/png",
# No Content-Disposition header = inline display
)
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse, Response
app = FastAPI()
@app.get("/robots.txt", response_class=PlainTextResponse)
def robots_txt():
return """User-agent: *
Disallow: /admin/
Allow: /
Sitemap: https://example.com/sitemap.xml"""
# XML response
@app.get("/sitemap.xml")
def sitemap():
xml_content = """<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://example.com/</loc></url>
</urlset>"""
return Response(
content=xml_content,
media_type="application/xml"
)
Proper HTTP status codes are essential for RESTful APIs. FastAPI provides the status module with named constants for all standard HTTP status codes.
from fastapi import FastAPI, status
app = FastAPI()
# Set status code in the decorator
@app.post("/items", status_code=status.HTTP_201_CREATED)
def create_item():
return {"id": 1, "name": "Widget"}
@app.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item(item_id: int):
# 204 responses should not have a body
return None
@app.get("/items", status_code=status.HTTP_200_OK)
def list_items():
return {"items": []}
| Constant | Code | Meaning | When to Use |
|---|---|---|---|
HTTP_200_OK |
200 | OK | Successful GET, PUT, PATCH |
HTTP_201_CREATED |
201 | Created | Successful POST that creates a resource |
HTTP_204_NO_CONTENT |
204 | No Content | Successful DELETE |
HTTP_301_MOVED_PERMANENTLY |
301 | Moved Permanently | URL has permanently changed |
HTTP_302_FOUND |
302 | Found | Temporary redirect |
HTTP_304_NOT_MODIFIED |
304 | Not Modified | Cached content is still valid |
HTTP_400_BAD_REQUEST |
400 | Bad Request | Client sent invalid data |
HTTP_401_UNAUTHORIZED |
401 | Unauthorized | Authentication required |
HTTP_403_FORBIDDEN |
403 | Forbidden | Authenticated but not authorized |
HTTP_404_NOT_FOUND |
404 | Not Found | Resource does not exist |
HTTP_409_CONFLICT |
409 | Conflict | Resource conflict (e.g., duplicate) |
HTTP_422_UNPROCESSABLE_ENTITY |
422 | Unprocessable Entity | Validation errors (FastAPI default) |
HTTP_429_TOO_MANY_REQUESTS |
429 | Too Many Requests | Rate limiting |
HTTP_500_INTERNAL_SERVER_ERROR |
500 | Internal Server Error | Unexpected server error |
from fastapi import FastAPI, Response, status
app = FastAPI()
items_db = {}
@app.put("/items/{item_id}")
def upsert_item(item_id: int, response: Response):
"""Create or update an item. Returns 201 for creation, 200 for update."""
if item_id in items_db:
items_db[item_id] = {"id": item_id, "updated": True}
response.status_code = status.HTTP_200_OK
return {"message": "Item updated", "item": items_db[item_id]}
else:
items_db[item_id] = {"id": item_id, "updated": False}
response.status_code = status.HTTP_201_CREATED
return {"message": "Item created", "item": items_db[item_id]}
from fastapi import FastAPI, HTTPException, status
app = FastAPI()
items_db = {
1: {"id": 1, "name": "Widget", "owner": "alice"},
2: {"id": 2, "name": "Gadget", "owner": "bob"},
}
@app.get("/items/{item_id}")
def get_item(item_id: int):
if item_id not in items_db:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Item with ID {item_id} not found",
headers={"X-Error-Code": "ITEM_NOT_FOUND"},
)
return items_db[item_id]
@app.delete("/items/{item_id}")
def delete_item(item_id: int, current_user: str = "alice"):
if item_id not in items_db:
raise HTTPException(
status_code=404,
detail="Item not found"
)
item = items_db[item_id]
if item["owner"] != current_user:
raise HTTPException(
status_code=403,
detail="You do not have permission to delete this item"
)
del items_db[item_id]
return {"message": f"Item {item_id} deleted"}
from fastapi import FastAPI, Response
from fastapi.responses import JSONResponse
app = FastAPI()
# Method 1: Using the Response parameter
@app.get("/items")
def get_items(response: Response):
response.headers["X-Total-Count"] = "42"
response.headers["X-Page"] = "1"
response.headers["X-Per-Page"] = "10"
response.headers["Cache-Control"] = "public, max-age=300"
return {"items": [{"id": 1}]}
# Method 2: Using JSONResponse directly
@app.get("/data")
def get_data():
return JSONResponse(
content={"data": "value"},
headers={
"X-Request-Id": "abc-123",
"X-Rate-Limit-Remaining": "99",
"X-Rate-Limit-Reset": "1609459200",
}
)
# Method 3: CORS and security headers via middleware
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://example.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Custom middleware for security headers
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Strict-Transport-Security"] = "max-age=31536000"
return response
app.add_middleware(SecurityHeadersMiddleware)
from datetime import datetime, timedelta
from fastapi import FastAPI, Response, Cookie
from typing import Optional
app = FastAPI()
# Setting cookies
@app.post("/login")
def login(response: Response):
# Secure session cookie
response.set_cookie(
key="session_id",
value="abc123def456",
httponly=True, # Not accessible via JavaScript
secure=True, # Only sent over HTTPS
samesite="lax", # CSRF protection
max_age=3600, # Expires in 1 hour
path="/", # Available on all paths
domain=None, # Current domain only
)
# Non-sensitive preference cookie
response.set_cookie(
key="theme",
value="dark",
httponly=False, # Accessible via JavaScript
max_age=86400 * 365, # Expires in 1 year
)
return {"message": "Login successful"}
# Reading cookies
@app.get("/dashboard")
def dashboard(
session_id: Optional[str] = Cookie(None),
theme: Optional[str] = Cookie("light"),
):
if not session_id:
return {"error": "Not authenticated"}
return {
"session": session_id,
"theme": theme,
"message": "Welcome to dashboard"
}
# Deleting cookies
@app.post("/logout")
def logout(response: Response):
response.delete_cookie(key="session_id", path="/")
response.delete_cookie(key="theme")
return {"message": "Logged out"}
httponly=True for session cookies to prevent XSS attackssecure=True in production to ensure cookies are only sent over HTTPSsamesite="lax" or samesite="strict" for CSRF protectionmax_age values — do not use indefinite cookies for sessionsAs your application grows, keeping all routes in a single file becomes unmanageable. FastAPI’s APIRouter lets you organize routes into separate modules with their own prefixes, tags, and dependencies — similar to Flask’s Blueprints.
myproject/ ├── main.py # Application entry point ├── routers/ │ ├── __init__.py │ ├── users.py # User-related routes │ ├── items.py # Item-related routes │ └── auth.py # Authentication routes ├── models/ │ ├── __init__.py │ ├── user.py # Pydantic models for users │ └── item.py # Pydantic models for items └── dependencies.py # Shared dependencies
# routers/users.py
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field, EmailStr
from typing import Optional, List
router = APIRouter(
prefix="/users",
tags=["Users"],
responses={
404: {"description": "User not found"},
},
)
class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=50)
email: str = Field(...)
full_name: Optional[str] = None
class UserResponse(BaseModel):
id: int
username: str
email: str
full_name: Optional[str] = None
# In-memory database for demonstration
users_db: dict[int, dict] = {}
next_id = 1
@router.get("/", response_model=List[UserResponse])
def list_users(skip: int = 0, limit: int = 10):
"""List all users with pagination."""
users = list(users_db.values())
return users[skip : skip + limit]
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(user: UserCreate):
"""Create a new user."""
global next_id
user_data = {"id": next_id, **user.model_dump()}
users_db[next_id] = user_data
next_id += 1
return user_data
@router.get("/{user_id}", response_model=UserResponse)
def get_user(user_id: int):
"""Get a user by ID."""
if user_id not in users_db:
raise HTTPException(status_code=404, detail="User not found")
return users_db[user_id]
@router.put("/{user_id}", response_model=UserResponse)
def update_user(user_id: int, user: UserCreate):
"""Update a user."""
if user_id not in users_db:
raise HTTPException(status_code=404, detail="User not found")
users_db[user_id] = {"id": user_id, **user.model_dump()}
return users_db[user_id]
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(user_id: int):
"""Delete a user."""
if user_id not in users_db:
raise HTTPException(status_code=404, detail="User not found")
del users_db[user_id]
# routers/items.py
from fastapi import APIRouter, HTTPException, Depends, status
from pydantic import BaseModel, Field
from typing import Optional, List
router = APIRouter(
prefix="/items",
tags=["Items"],
)
class ItemCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
price: float = Field(..., gt=0)
category: str = Field(..., min_length=1)
class ItemResponse(BaseModel):
id: int
name: str
description: Optional[str]
price: float
category: str
owner_id: int
items_db: dict[int, dict] = {}
next_id = 1
@router.get("/", response_model=List[ItemResponse])
def list_items(
category: Optional[str] = None,
min_price: Optional[float] = None,
max_price: Optional[float] = None,
skip: int = 0,
limit: int = 20,
):
"""List items with optional filters."""
items = list(items_db.values())
if category:
items = [i for i in items if i["category"] == category]
if min_price is not None:
items = [i for i in items if i["price"] >= min_price]
if max_price is not None:
items = [i for i in items if i["price"] <= max_price]
return items[skip : skip + limit]
@router.post("/", response_model=ItemResponse, status_code=status.HTTP_201_CREATED)
def create_item(item: ItemCreate):
"""Create a new item."""
global next_id
item_data = {"id": next_id, "owner_id": 1, **item.model_dump()}
items_db[next_id] = item_data
next_id += 1
return item_data
@router.get("/{item_id}", response_model=ItemResponse)
def get_item(item_id: int):
"""Get an item by ID."""
if item_id not in items_db:
raise HTTPException(status_code=404, detail="Item not found")
return items_db[item_id]
# main.py
from fastapi import FastAPI
from routers import users, items
app = FastAPI(
title="My Application",
description="A well-organized FastAPI application",
version="1.0.0",
)
# Include routers
app.include_router(users.router)
app.include_router(items.router)
# You can also override prefix and tags when including
# app.include_router(
# users.router,
# prefix="/api/v1/users", # Override prefix
# tags=["Users V1"], # Override tags
# )
@app.get("/")
def root():
return {"message": "Welcome to the API"}
@app.get("/health")
def health_check():
return {"status": "healthy"}
# routers/admin/__init__.py
from fastapi import APIRouter
from . import dashboard, settings
router = APIRouter(prefix="/admin", tags=["Admin"])
router.include_router(dashboard.router)
router.include_router(settings.router)
# routers/admin/dashboard.py
from fastapi import APIRouter
router = APIRouter(prefix="/dashboard")
@router.get("/")
def admin_dashboard():
"""GET /admin/dashboard/"""
return {"message": "Admin dashboard"}
@router.get("/stats")
def admin_stats():
"""GET /admin/dashboard/stats"""
return {"users": 100, "items": 500}
# routers/admin/settings.py
from fastapi import APIRouter
router = APIRouter(prefix="/settings")
@router.get("/")
def admin_settings():
"""GET /admin/settings/"""
return {"theme": "dark", "language": "en"}
from fastapi import APIRouter, Depends, Header, HTTPException
# Dependency: require API key for all routes in this router
async def verify_api_key(x_api_key: str = Header(...)):
if x_api_key != "secret-api-key":
raise HTTPException(status_code=403, detail="Invalid API key")
return x_api_key
# All routes in this router require the API key
router = APIRouter(
prefix="/internal",
tags=["Internal"],
dependencies=[Depends(verify_api_key)],
)
@router.get("/metrics")
def get_metrics():
return {"cpu": 45.2, "memory": 68.1}
@router.get("/logs")
def get_logs():
return {"logs": ["entry1", "entry2"]}
FastAPI’s dependency injection system is one of its most powerful features. Dependencies let you share logic across routes — authentication, database connections, pagination, logging, and more.
from fastapi import FastAPI, Depends, Query
app = FastAPI()
# Dependency function for common pagination parameters
def pagination_params(
page: int = Query(1, ge=1, description="Page number"),
per_page: int = Query(20, ge=1, le=100, description="Items per page"),
):
"""Reusable pagination dependency."""
skip = (page - 1) * per_page
return {"skip": skip, "limit": per_page, "page": page}
@app.get("/users")
def list_users(pagination: dict = Depends(pagination_params)):
fake_users = [{"id": i, "name": f"User {i}"} for i in range(100)]
users = fake_users[pagination["skip"]: pagination["skip"] + pagination["limit"]]
return {
"page": pagination["page"],
"per_page": pagination["limit"],
"users": users
}
@app.get("/items")
def list_items(pagination: dict = Depends(pagination_params)):
fake_items = [{"id": i, "name": f"Item {i}"} for i in range(200)]
items = fake_items[pagination["skip"]: pagination["skip"] + pagination["limit"]]
return {
"page": pagination["page"],
"per_page": pagination["limit"],
"items": items
}
from fastapi import FastAPI, Depends, Query
from typing import Optional
app = FastAPI()
class PaginationParams:
"""Callable class that works as a dependency."""
def __init__(
self,
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
sort_by: Optional[str] = Query(None),
order: str = Query("asc", pattern="^(asc|desc)$"),
):
self.page = page
self.per_page = per_page
self.skip = (page - 1) * per_page
self.sort_by = sort_by
self.order = order
@app.get("/products")
def list_products(params: PaginationParams = Depends()):
"""When using Depends() without arguments on a class,
FastAPI uses the class itself as the dependency."""
return {
"page": params.page,
"per_page": params.per_page,
"sort_by": params.sort_by,
"order": params.order,
}
from fastapi import FastAPI, Depends, HTTPException, Header, status
app = FastAPI()
# Level 1: Extract token
def get_token(authorization: str = Header(...)) -> str:
if not authorization.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authorization header"
)
return authorization[7:]
# Level 2: Decode token and get user (depends on Level 1)
def get_current_user(token: str = Depends(get_token)) -> dict:
# In production, decode JWT and query database
if token == "valid-token":
return {"id": 1, "username": "alice", "role": "admin"}
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token"
)
# Level 3: Check admin role (depends on Level 2)
def require_admin(user: dict = Depends(get_current_user)) -> dict:
if user["role"] != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required"
)
return user
# Public route — no authentication needed
@app.get("/public")
def public_endpoint():
return {"message": "Anyone can access this"}
# Authenticated route — requires valid token
@app.get("/profile")
def get_profile(user: dict = Depends(get_current_user)):
return {"user": user}
# Admin-only route — requires admin role
@app.get("/admin/users")
def admin_list_users(admin: dict = Depends(require_admin)):
return {"admin": admin["username"], "users": ["alice", "bob", "charlie"]}
from fastapi import FastAPI, Depends
from typing import Generator
app = FastAPI()
# Simulating a database session
class DatabaseSession:
def __init__(self):
self.connected = True
print("Database session opened")
def query(self, sql: str):
return [{"id": 1}]
def close(self):
self.connected = False
print("Database session closed")
def get_db() -> Generator[DatabaseSession, None, None]:
"""Dependency with cleanup — session is closed after the request."""
db = DatabaseSession()
try:
yield db
finally:
db.close()
@app.get("/users")
def get_users(db: DatabaseSession = Depends(get_db)):
"""The database session is automatically closed after this handler completes,
even if an exception occurs."""
results = db.query("SELECT * FROM users")
return {"users": results}
from fastapi import FastAPI, Depends, Header, HTTPException
import time
# Dependency applied to ALL routes
async def log_request(x_request_id: str = Header(None)):
"""Log every incoming request."""
request_id = x_request_id or f"auto-{int(time.time())}"
print(f"Request ID: {request_id}")
return request_id
app = FastAPI(dependencies=[Depends(log_request)])
@app.get("/")
def root():
return {"message": "This route also has the log_request dependency"}
@app.get("/items")
def items():
return {"message": "So does this one"}
Sometimes you need access to the raw request object for information that is not covered by FastAPI’s parameter declarations. You can access it by adding a parameter of type Request.
from fastapi import FastAPI, Request
app = FastAPI()
@app.get("/request-info")
async def request_info(request: Request):
"""Access detailed information about the incoming request."""
return {
# URL information
"url": str(request.url),
"base_url": str(request.base_url),
"path": request.url.path,
"query_string": str(request.query_params),
# Client information
"client_host": request.client.host if request.client else None,
"client_port": request.client.port if request.client else None,
# Request metadata
"method": request.method,
"headers": dict(request.headers),
"cookies": request.cookies,
# Path parameters
"path_params": request.path_params,
}
@app.post("/webhook")
async def webhook(request: Request):
"""Read raw body for webhook signature verification."""
body = await request.body()
content_type = request.headers.get("content-type", "")
if "application/json" in content_type:
json_body = await request.json()
return {"type": "json", "data": json_body}
else:
return {"type": "raw", "size": len(body)}
# Practical: Request-scoped state
from starlette.middleware.base import BaseHTTPMiddleware
import uuid
import time
class RequestContextMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# Attach metadata to request.state for use in route handlers
request.state.request_id = str(uuid.uuid4())
request.state.start_time = time.time()
response = await call_next(request)
duration = time.time() - request.state.start_time
response.headers["X-Request-Id"] = request.state.request_id
response.headers["X-Process-Time"] = f"{duration:.4f}"
return response
app.add_middleware(RequestContextMiddleware)
@app.get("/with-context")
async def with_context(request: Request):
return {
"request_id": request.state.request_id,
"message": "This request has context from middleware"
}
Let us bring everything together by building a complete URL shortener service. This practical example demonstrates path parameters, query parameters, request bodies, headers, cookies, multiple response types, dependency injection, and API routers — all in one application.
url_shortener/ ├── main.py # Application entry point and configuration ├── models.py # Pydantic models ├── database.py # In-memory database (replace with real DB in production) ├── dependencies.py # Shared dependencies ├── routers/ │ ├── __init__.py │ ├── urls.py # URL shortening routes │ ├── analytics.py # Analytics routes │ └── admin.py # Admin routes └── requirements.txt
# models.py
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, Field, HttpUrl
class URLCreate(BaseModel):
"""Request body for creating a shortened URL."""
original_url: str = Field(
...,
min_length=10,
max_length=2048,
description="The original URL to shorten",
examples=["https://www.example.com/very/long/path"]
)
custom_code: Optional[str] = Field(
None,
min_length=3,
max_length=20,
pattern="^[a-zA-Z0-9_-]+$",
description="Custom short code (optional)"
)
expires_in_hours: Optional[int] = Field(
None,
ge=1,
le=8760, # Max 1 year
description="Hours until the link expires"
)
tags: List[str] = Field(
default_factory=list,
max_length=5,
description="Tags for organizing URLs"
)
class URLResponse(BaseModel):
"""Response for a shortened URL."""
short_code: str
short_url: str
original_url: str
created_at: str
expires_at: Optional[str] = None
click_count: int = 0
tags: List[str] = []
class URLStats(BaseModel):
"""Analytics data for a shortened URL."""
short_code: str
original_url: str
total_clicks: int
unique_visitors: int
clicks_by_day: dict
top_referrers: List[dict]
top_countries: List[dict]
created_at: str
class URLBulkCreate(BaseModel):
"""Request body for bulk URL creation."""
urls: List[URLCreate] = Field(
...,
min_length=1,
max_length=100,
description="List of URLs to shorten"
)
# database.py
import string
import random
from datetime import datetime, timedelta
from typing import Optional, Dict, List
from collections import defaultdict
class URLDatabase:
"""In-memory URL database. Replace with a real database in production."""
def __init__(self):
self.urls: Dict[str, dict] = {}
self.clicks: Dict[str, List[dict]] = defaultdict(list)
def generate_code(self, length: int = 6) -> str:
"""Generate a random short code."""
chars = string.ascii_letters + string.digits
while True:
code = "".join(random.choices(chars, k=length))
if code not in self.urls:
return code
def create_url(
self,
original_url: str,
custom_code: Optional[str] = None,
expires_in_hours: Optional[int] = None,
tags: Optional[List[str]] = None,
) -> dict:
"""Create a new shortened URL."""
code = custom_code or self.generate_code()
if code in self.urls:
raise ValueError(f"Code '{code}' is already taken")
now = datetime.utcnow()
expires_at = None
if expires_in_hours:
expires_at = now + timedelta(hours=expires_in_hours)
url_data = {
"short_code": code,
"original_url": original_url,
"created_at": now.isoformat(),
"expires_at": expires_at.isoformat() if expires_at else None,
"click_count": 0,
"tags": tags or [],
"is_active": True,
}
self.urls[code] = url_data
return url_data
def get_url(self, code: str) -> Optional[dict]:
"""Get URL data by short code."""
url_data = self.urls.get(code)
if not url_data:
return None
# Check expiration
if url_data["expires_at"]:
expires_at = datetime.fromisoformat(url_data["expires_at"])
if datetime.utcnow() > expires_at:
url_data["is_active"] = False
return None
return url_data
def record_click(self, code: str, visitor_ip: str, referrer: str, user_agent: str):
"""Record a click event."""
if code in self.urls:
self.urls[code]["click_count"] += 1
self.clicks[code].append({
"timestamp": datetime.utcnow().isoformat(),
"visitor_ip": visitor_ip,
"referrer": referrer,
"user_agent": user_agent,
})
def get_stats(self, code: str) -> Optional[dict]:
"""Get analytics for a URL."""
url_data = self.urls.get(code)
if not url_data:
return None
clicks = self.clicks.get(code, [])
unique_ips = set(c["visitor_ip"] for c in clicks)
# Group clicks by day
clicks_by_day = defaultdict(int)
for click in clicks:
day = click["timestamp"][:10]
clicks_by_day[day] += 1
# Top referrers
referrer_counts = defaultdict(int)
for click in clicks:
ref = click["referrer"] or "Direct"
referrer_counts[ref] += 1
top_referrers = [
{"referrer": r, "count": c}
for r, c in sorted(referrer_counts.items(), key=lambda x: -x[1])[:10]
]
return {
"short_code": code,
"original_url": url_data["original_url"],
"total_clicks": url_data["click_count"],
"unique_visitors": len(unique_ips),
"clicks_by_day": dict(clicks_by_day),
"top_referrers": top_referrers,
"top_countries": [], # Would require GeoIP in production
"created_at": url_data["created_at"],
}
def list_urls(
self,
skip: int = 0,
limit: int = 20,
tag: Optional[str] = None,
) -> List[dict]:
"""List all URLs with optional filtering."""
urls = list(self.urls.values())
if tag:
urls = [u for u in urls if tag in u["tags"]]
return urls[skip : skip + limit]
def delete_url(self, code: str) -> bool:
"""Delete a URL."""
if code in self.urls:
del self.urls[code]
self.clicks.pop(code, None)
return True
return False
# Singleton database instance
db = URLDatabase()
# dependencies.py
from fastapi import Depends, Header, HTTPException, Query, Request, status
from database import URLDatabase, db
from typing import Optional
def get_database() -> URLDatabase:
"""Provide the database instance."""
return db
class PaginationParams:
"""Reusable pagination dependency."""
def __init__(
self,
page: int = Query(1, ge=1, description="Page number"),
per_page: int = Query(20, ge=1, le=100, description="Items per page"),
):
self.page = page
self.per_page = per_page
self.skip = (page - 1) * per_page
def get_api_key(x_api_key: str = Header(..., description="API key")) -> str:
"""Validate API key for protected routes."""
valid_keys = {"admin-key-001", "admin-key-002"}
if x_api_key not in valid_keys:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid API key"
)
return x_api_key
def get_client_info(request: Request) -> dict:
"""Extract client information from the request."""
return {
"ip": request.client.host if request.client else "unknown",
"user_agent": request.headers.get("user-agent", "unknown"),
"referrer": request.headers.get("referer", ""),
}
# routers/urls.py
from fastapi import APIRouter, Depends, HTTPException, Response, status, Cookie
from fastapi.responses import RedirectResponse, JSONResponse
from typing import Optional, List
from models import URLCreate, URLResponse, URLBulkCreate
from database import URLDatabase
from dependencies import get_database, PaginationParams, get_client_info
router = APIRouter(tags=["URLs"])
BASE_URL = "http://localhost:8000"
@router.post(
"/shorten",
response_model=URLResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a shortened URL",
)
def create_short_url(
url_data: URLCreate,
response: Response,
db: URLDatabase = Depends(get_database),
):
"""Create a new shortened URL with optional custom code and expiration."""
try:
url = db.create_url(
original_url=url_data.original_url,
custom_code=url_data.custom_code,
expires_in_hours=url_data.expires_in_hours,
tags=url_data.tags,
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(e)
)
short_url = f"{BASE_URL}/{url['short_code']}"
response.headers["Location"] = short_url
return URLResponse(
short_code=url["short_code"],
short_url=short_url,
original_url=url["original_url"],
created_at=url["created_at"],
expires_at=url["expires_at"],
click_count=0,
tags=url["tags"],
)
@router.post(
"/shorten/bulk",
response_model=List[URLResponse],
status_code=status.HTTP_201_CREATED,
summary="Create multiple shortened URLs",
)
def create_bulk_urls(
bulk: URLBulkCreate,
db: URLDatabase = Depends(get_database),
):
"""Create multiple shortened URLs in a single request."""
results = []
for url_data in bulk.urls:
try:
url = db.create_url(
original_url=url_data.original_url,
custom_code=url_data.custom_code,
expires_in_hours=url_data.expires_in_hours,
tags=url_data.tags,
)
results.append(URLResponse(
short_code=url["short_code"],
short_url=f"{BASE_URL}/{url['short_code']}",
original_url=url["original_url"],
created_at=url["created_at"],
expires_at=url["expires_at"],
tags=url["tags"],
))
except ValueError:
continue # Skip duplicates in bulk operations
return results
@router.get(
"/urls",
response_model=List[URLResponse],
summary="List all shortened URLs",
)
def list_urls(
pagination: PaginationParams = Depends(),
tag: Optional[str] = None,
db: URLDatabase = Depends(get_database),
):
"""List all URLs with pagination and optional tag filter."""
urls = db.list_urls(
skip=pagination.skip,
limit=pagination.per_page,
tag=tag,
)
return [
URLResponse(
short_code=u["short_code"],
short_url=f"{BASE_URL}/{u['short_code']}",
original_url=u["original_url"],
created_at=u["created_at"],
expires_at=u["expires_at"],
click_count=u["click_count"],
tags=u["tags"],
)
for u in urls
]
@router.get(
"/{short_code}",
summary="Redirect to original URL",
responses={
307: {"description": "Redirect to original URL"},
404: {"description": "Short URL not found"},
},
)
def redirect_to_url(
short_code: str,
response: Response,
client: dict = Depends(get_client_info),
visitor_id: Optional[str] = Cookie(None),
db: URLDatabase = Depends(get_database),
):
"""Redirect to the original URL and track the click."""
url_data = db.get_url(short_code)
if not url_data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Short URL '{short_code}' not found or has expired"
)
# Record the click
db.record_click(
code=short_code,
visitor_ip=client["ip"],
referrer=client["referrer"],
user_agent=client["user_agent"],
)
# Set visitor cookie if not present
redirect = RedirectResponse(
url=url_data["original_url"],
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
if not visitor_id:
import uuid
redirect.set_cookie(
key="visitor_id",
value=str(uuid.uuid4()),
max_age=86400 * 365,
httponly=True,
)
return redirect
@router.get(
"/{short_code}/info",
response_model=URLResponse,
summary="Get URL information without redirecting",
)
def get_url_info(
short_code: str,
db: URLDatabase = Depends(get_database),
):
"""Get information about a shortened URL without triggering a redirect."""
url_data = db.get_url(short_code)
if not url_data:
raise HTTPException(status_code=404, detail="URL not found")
return URLResponse(
short_code=url_data["short_code"],
short_url=f"{BASE_URL}/{url_data['short_code']}",
original_url=url_data["original_url"],
created_at=url_data["created_at"],
expires_at=url_data["expires_at"],
click_count=url_data["click_count"],
tags=url_data["tags"],
)
@router.delete(
"/{short_code}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a shortened URL",
)
def delete_url(
short_code: str,
db: URLDatabase = Depends(get_database),
):
"""Delete a shortened URL."""
if not db.delete_url(short_code):
raise HTTPException(status_code=404, detail="URL not found")
# routers/analytics.py
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from typing import Optional
from models import URLStats
from database import URLDatabase
from dependencies import get_database
router = APIRouter(
prefix="/analytics",
tags=["Analytics"],
)
@router.get(
"/{short_code}",
response_model=URLStats,
summary="Get URL analytics",
)
def get_url_analytics(
short_code: str,
db: URLDatabase = Depends(get_database),
):
"""Get detailed analytics for a shortened URL."""
stats = db.get_stats(short_code)
if not stats:
raise HTTPException(status_code=404, detail="URL not found")
return stats
@router.get(
"/{short_code}/export",
summary="Export click data as CSV",
)
def export_clicks_csv(
short_code: str,
db: URLDatabase = Depends(get_database),
):
"""Export click data for a URL as a CSV file using StreamingResponse."""
url_data = db.get_url(short_code)
if not url_data:
raise HTTPException(status_code=404, detail="URL not found")
clicks = db.clicks.get(short_code, [])
def generate_csv():
yield "timestamp,visitor_ip,referrer,user_agent\n"
for click in clicks:
yield (
f"{click['timestamp']},"
f"{click['visitor_ip']},"
f"{click['referrer']},"
f"{click['user_agent']}\n"
)
return StreamingResponse(
generate_csv(),
media_type="text/csv",
headers={
"Content-Disposition": f"attachment; filename={short_code}_clicks.csv"
},
)
# routers/admin.py
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from database import URLDatabase
from dependencies import get_database, get_api_key
router = APIRouter(
prefix="/admin",
tags=["Admin"],
dependencies=[Depends(get_api_key)], # All admin routes require API key
)
@router.get("/dashboard")
def admin_dashboard(db: URLDatabase = Depends(get_database)):
"""Admin dashboard with overview statistics."""
total_urls = len(db.urls)
total_clicks = sum(u["click_count"] for u in db.urls.values())
active_urls = sum(1 for u in db.urls.values() if u["is_active"])
return {
"total_urls": total_urls,
"active_urls": active_urls,
"total_clicks": total_clicks,
"avg_clicks_per_url": round(total_clicks / max(total_urls, 1), 2),
}
@router.delete("/purge-expired")
def purge_expired(db: URLDatabase = Depends(get_database)):
"""Remove all expired URLs."""
from datetime import datetime
expired_codes = []
for code, url_data in list(db.urls.items()):
if url_data["expires_at"]:
expires_at = datetime.fromisoformat(url_data["expires_at"])
if datetime.utcnow() > expires_at:
expired_codes.append(code)
db.delete_url(code)
return {
"purged_count": len(expired_codes),
"purged_codes": expired_codes,
}
# main.py
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from routers import urls, analytics, admin
import time
import uuid
# Create the application
app = FastAPI(
title="URL Shortener",
description="A feature-rich URL shortener built with FastAPI",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Custom middleware for request tracking
class RequestTrackingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
request_id = str(uuid.uuid4())
start_time = time.time()
request.state.request_id = request_id
response = await call_next(request)
duration = time.time() - start_time
response.headers["X-Request-Id"] = request_id
response.headers["X-Process-Time"] = f"{duration:.4f}s"
return response
app.add_middleware(RequestTrackingMiddleware)
# Include routers
app.include_router(urls.router)
app.include_router(analytics.router)
app.include_router(admin.router)
# Root endpoint
@app.get("/", tags=["Root"])
def root():
return {
"service": "URL Shortener",
"version": "1.0.0",
"docs": "/docs",
"endpoints": {
"shorten": "POST /shorten",
"redirect": "GET /{short_code}",
"info": "GET /{short_code}/info",
"analytics": "GET /analytics/{short_code}",
"admin": "GET /admin/dashboard",
},
}
# Health check
@app.get("/health", tags=["Root"])
def health_check():
return {"status": "healthy", "timestamp": time.time()}
# Global exception handler
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={
"detail": "Internal server error",
"request_id": getattr(request.state, "request_id", "unknown"),
},
)
# Start the server
uvicorn main:app --reload
# Create a shortened URL
curl -X POST http://localhost:8000/shorten \
-H "Content-Type: application/json" \
-d '{"original_url": "https://www.python.org/doc/", "tags": ["python", "docs"]}'
# Create with custom code
curl -X POST http://localhost:8000/shorten \
-H "Content-Type: application/json" \
-d '{"original_url": "https://fastapi.tiangolo.com", "custom_code": "fastapi", "expires_in_hours": 720}'
# Visit the shortened URL (will redirect)
curl -L http://localhost:8000/fastapi
# Get URL info without redirecting
curl http://localhost:8000/fastapi/info
# Get analytics
curl http://localhost:8000/analytics/fastapi
# Export clicks as CSV
curl http://localhost:8000/analytics/fastapi/export -o clicks.csv
# List all URLs with pagination
curl "http://localhost:8000/urls?page=1&per_page=10"
# Filter by tag
curl "http://localhost:8000/urls?tag=python"
# Admin dashboard (requires API key)
curl http://localhost:8000/admin/dashboard \
-H "X-Api-Key: admin-key-001"
# Bulk create
curl -X POST http://localhost:8000/shorten/bulk \
-H "Content-Type: application/json" \
-d '{"urls": [
{"original_url": "https://python.org"},
{"original_url": "https://fastapi.tiangolo.com"},
{"original_url": "https://pydantic.dev"}
]}'
# Delete a URL
curl -X DELETE http://localhost:8000/fastapi
# Purge expired URLs (admin)
curl -X DELETE http://localhost:8000/admin/purge-expired \
-H "X-Api-Key: admin-key-001"
# requirements.txt fastapi==0.115.0 uvicorn[standard]==0.30.0 python-multipart==0.0.9 aiofiles==24.1.0 httpx==0.27.0
| Topic | Key Points |
|---|---|
| Route Basics | Use @app.get(), @app.post(), etc. decorators. Route order matters for overlapping patterns. Use async def for I/O-bound handlers. |
| Path Parameters | Captured from URL path with type annotations. Use Path() for validation (ge, le, pattern). Enum types restrict to predefined values. |
| Query Parameters | Function params without path match become query params. Use Query() for validation, defaults, aliases, and deprecation. Lists with List[str] accept repeated params. |
| Headers | Use Header() to extract HTTP headers. Underscores convert to hyphens automatically. Supports validation like other params. |
| Request Body | Pydantic models auto-validate JSON bodies. Nested models for complex structures. Body() for singular values alongside models. |
| Cookies | Cookie() reads cookies. response.set_cookie() and response.delete_cookie() manage cookies. Always use httponly=True for sessions. |
| Form Data & Files | Form() for URL-encoded data. UploadFile for files with async read/write. Requires python-multipart package. |
| Responses | JSONResponse (default), HTMLResponse, RedirectResponse, StreamingResponse, FileResponse, PlainTextResponse. Set response_class in decorator. |
| Status Codes | Use status.HTTP_xxx_* constants. Set in decorator or dynamically via Response parameter. HTTPException for errors. |
| Response Headers/Cookies | Set via Response parameter or return custom response objects. Use middleware for global headers (security, CORS). |
| APIRouter | Organize routes into modules with prefix and tags. Include with app.include_router(). Support nested routers and shared dependencies. |
| Dependencies | Depends() for dependency injection. Function or class-based. Chain dependencies. Use yield for cleanup. Apply at route, router, or app level. |
| Request Object | Access raw Request for URL, headers, cookies, body, client info. Use request.state for middleware-injected data. |
FastAPI is a modern, high-performance web framework for building APIs with Python 3.7+ based on standard Python type hints. Created by Sebastián Ramírez (tiangolo) and first released in December 2018, FastAPI has rapidly become one of the most popular Python web frameworks, earning over 75,000 GitHub stars and adoption by companies like Microsoft, Netflix, Uber, and Samsung.
FastAPI is built on two foundational libraries:
What makes FastAPI stand out from other Python web frameworks is its philosophy: leverage modern Python features — type hints, async/await, and dataclasses — to eliminate boilerplate, catch errors at development time, and automatically generate production-ready API documentation. You write less code, and the code you write is more correct.
The name “FastAPI” refers to two things: the speed of the framework itself (comparable to Node.js and Go in benchmarks) and the speed at which you can develop APIs with it. Sebastián Ramírez estimates that FastAPI increases developer productivity by 200-300% compared to traditional approaches, while simultaneously reducing bugs by about 40% through its type-driven validation system.
This tutorial series will take you from zero to building production-ready APIs with FastAPI. We will cover everything a senior developer needs to know — from basic routing to database integration, authentication, testing, and deployment. By the end, you will have the skills to architect and build real-world applications with FastAPI.
Before diving into code, you need to understand why FastAPI has taken the Python ecosystem by storm. There are five core reasons that make it the framework of choice for modern Python API development.
FastAPI consistently ranks among the fastest Python web frameworks in independent benchmarks. Built on Starlette and running on Uvicorn (an ASGI server powered by uvloop and httptools), FastAPI achieves throughput numbers that rival Node.js and Go frameworks.
| Framework | Language | Requests/sec (JSON serialization) | Relative Speed |
|---|---|---|---|
| Uvicorn (raw ASGI) | Python | ~70,000 | 1.0x (baseline) |
| FastAPI | Python | ~50,000-60,000 | 0.8x |
| Express.js | Node.js | ~45,000-55,000 | 0.75x |
| Flask (Gunicorn) | Python | ~8,000-12,000 | 0.15x |
| Django (Gunicorn) | Python | ~5,000-8,000 | 0.10x |
| Gin | Go | ~80,000-100,000 | 1.3x |
The performance advantage over Flask and Django comes from two architectural decisions:
uvloop, a drop-in replacement for Python’s asyncio event loop written in Cython. It is 2-4x faster than the default event loop and comparable to the event loop implementations in Node.js and Go.FastAPI automatically generates interactive API documentation from your code. You get two documentation interfaces out of the box, with zero additional configuration:
/docs) — An interactive documentation page where you can read about every endpoint, see request/response schemas, and test endpoints directly from the browser. Click “Try it out,” fill in parameters, hit “Execute,” and see the response immediately./redoc) — A cleaner, more readable documentation view that is ideal for sharing with frontend developers, QA teams, or external API consumers. ReDoc generates a three-panel layout with navigation, endpoint details, and request/response examples.Both documentation interfaces are generated from the OpenAPI (formerly Swagger) schema that FastAPI creates automatically from your route definitions, type hints, and Pydantic models. Every parameter type, validation rule, response model, and example you define in code appears in the docs instantly. There is no separate documentation file to maintain, no YAML spec to write by hand, and no risk of your docs drifting out of sync with your code.
FastAPI uses Python type hints not just for documentation, but for runtime data validation. When you declare that a parameter is an int, FastAPI will automatically reject requests that send a string. When you define a Pydantic model with constraints, FastAPI validates every incoming request against that model before your handler function ever runs.
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=50)
email: str = Field(..., pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$")
age: int = Field(..., ge=13, le=120)
@app.post("/users")
async def create_user(user: UserCreate):
# If we reach this line, the data is guaranteed valid
# FastAPI already validated username length, email format, and age range
return {"message": f"User {user.username} created successfully"}
If a client sends {"username": "ab", "email": "not-an-email", "age": 5}, FastAPI returns a detailed 422 Unprocessable Entity response listing every validation error — without you writing a single line of validation code.
FastAPI was built from the ground up for Python’s async/await syntax. Unlike Flask, which bolted on async support years after its initial release, FastAPI treats asynchronous programming as a first-class citizen.
import httpx
from fastapi import FastAPI
app = FastAPI()
@app.get("/weather/{city}")
async def get_weather(city: str):
# Non-blocking HTTP call - server handles other requests while waiting
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.weatherapi.com/v1/current.json?q={city}"
)
return response.json()
With async, a single server process can handle thousands of concurrent connections. This is critical for modern API backends that aggregate data from multiple services, interact with databases, or serve real-time data over WebSockets.
def instead of async def. FastAPI will run it in a thread pool to avoid blocking the event loop. You can mix sync and async handlers freely in the same application.
FastAPI dramatically reduces the amount of code you need to write. Features that require dozens of lines in other frameworks — request validation, serialization, documentation, error handling — happen automatically. Consider what you get for free just by adding type hints:
Your IDE also becomes dramatically more helpful. Because FastAPI uses standard Python type hints, editors like VS Code and PyCharm provide accurate autocompletion, inline error detection, and refactoring support. You catch bugs before running your code, not after deploying it.
Choosing the right Python web framework is one of the most important architectural decisions you will make. Each framework has distinct strengths, and the right choice depends on your project requirements, team experience, and deployment constraints. Here is a detailed comparison.
| Feature | FastAPI | Flask | Django |
|---|---|---|---|
| Released | 2018 | 2010 | 2005 |
| Type | ASGI (async-first) | WSGI (sync, async bolt-on) | WSGI (async views since 3.1) |
| Performance | Very high (~50K req/s) | Moderate (~10K req/s) | Moderate (~7K req/s) |
| Async Support | Native, first-class | Limited (since 2.0) | Partial (views only, ORM sync) |
| Data Validation | Built-in (Pydantic) | Manual or third-party (marshmallow) | Built-in (forms/serializers) |
| API Documentation | Auto-generated (Swagger + ReDoc) | Manual or third-party (flasgger) | Manual or third-party (DRF) |
| ORM | None built-in (use SQLAlchemy, Tortoise) | None built-in (use SQLAlchemy) | Built-in Django ORM |
| Admin Panel | None built-in | None built-in (Flask-Admin) | Built-in, powerful |
| Authentication | OAuth2, JWT via dependencies | Third-party (Flask-Login, JWT) | Built-in (sessions, permissions) |
| Template Engine | Jinja2 (optional) | Jinja2 (built-in) | Django Templates (built-in) |
| Type Hints | Required, core feature | Optional, decorative | Optional, decorative |
| WebSocket Support | Built-in, native | Third-party (Flask-SocketIO) | Django Channels (add-on) |
| Learning Curve | Low-Medium | Low | Medium-High |
| Ecosystem Size | Growing rapidly | Very large, mature | Largest, most mature |
| Best For | APIs, microservices, async backends | Simple APIs, prototypes, learning | Full-stack web apps, CMS, e-commerce |
| Deployment | Uvicorn / Gunicorn + Uvicorn workers | Gunicorn / uWSGI | Gunicorn / uWSGI |
Before installing FastAPI, ensure you have the following:
async/await syntax. We recommend Python 3.10+ for the best experience (better error messages, structural pattern matching, and improved type hint syntax).Check your Python version:
python3 --version # Python 3.11.6 (or higher)
Create a dedicated directory for your FastAPI project and set up a virtual environment:
# Create project directory mkdir fastapi-tutorial cd fastapi-tutorial # Create virtual environment python3 -m venv venv # Activate virtual environment # On macOS/Linux: source venv/bin/activate # On Windows: # venv\Scripts\activate # Verify activation (should show venv path) which python
FastAPI itself is just the framework. You also need an ASGI server to run your application. Uvicorn is the recommended choice.
# Install FastAPI with all optional dependencies pip install "fastapi[standard]"
The fastapi[standard] install includes:
uvloop, httptools)pip install fastapi uvicorn. The [standard] extras are recommended because they include everything you will need for a real project.
Alternatively, install components individually for more control:
# Minimal installation pip install fastapi uvicorn # Add extras as needed pip install python-multipart # For form data pip install python-jose[cryptography] # For JWT tokens pip install passlib[bcrypt] # For password hashing pip install sqlalchemy # For database ORM pip install alembic # For database migrations
Verify the installation:
pip show fastapi
Expected output:
Name: fastapi Version: 0.115.x Summary: FastAPI framework, high performance, easy to learn, fast to code, ready for production Home-page: https://github.com/fastapi/fastapi Author-email: Sebastián Ramírez <tiangolo@gmail.com> License: MIT Requires: pydantic, starlette, typing-extensions
Always save your installed packages to a requirements file so your project is reproducible:
# Save current dependencies pip freeze > requirements.txt # Later, install from requirements file pip install -r requirements.txt
Let us build a “Hello World” application to see FastAPI in action. Create a file called main.py:
from fastapi import FastAPI
# Create the FastAPI application instance
app = FastAPI()
# Define a route using a path operation decorator
@app.get("/")
async def root():
"""Root endpoint that returns a welcome message."""
return {"message": "Hello, World!"}
That is it. Four lines of actual code (plus the import) and you have a fully functional API with automatic documentation. Let us break down what each line does:
from fastapi import FastAPI — Import the FastAPI class from the framework.app = FastAPI() — Create an application instance. This is the central object that holds all your routes, middleware, event handlers, and configuration. By convention, this variable is named app.@app.get("/") — A path operation decorator. It tells FastAPI that the function below handles GET requests to the path /. The @app.get decorator registers the function as a handler for HTTP GET requests.async def root() — The path operation function (also called a route handler or endpoint function). It is declared as async because FastAPI is an async framework. This function runs whenever a GET request hits /.return {"message": "Hello, World!"} — Return a Python dictionary. FastAPI automatically converts this to a JSON response with the correct Content-Type: application/json header and a 200 status code.Start the development server with Uvicorn:
uvicorn main:app --reload
Let us break down this command:
main — The Python module (file) name, main.pyapp — The FastAPI application instance inside that module--reload — Enable auto-reload. Uvicorn watches your files and automatically restarts the server when you save changes. Use this only in development, never in production.You should see output like this:
INFO: Will watch for changes in these directories: ['/path/to/fastapi-tutorial'] INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) INFO: Started reloader process [28720] using StatReload INFO: Started server process [28722] INFO: Waiting for application startup. INFO: Application startup complete.
Open your browser and navigate to http://127.0.0.1:8000. You should see:
{"message": "Hello, World!"}
Or test with curl from another terminal:
curl http://127.0.0.1:8000
# {"message":"Hello, World!"}
Let us expand our application with a few more endpoints to demonstrate different features:
from fastapi import FastAPI
app = FastAPI(
title="My First FastAPI App",
description="A tutorial application to learn FastAPI basics",
version="1.0.0"
)
@app.get("/")
async def root():
"""Root endpoint - welcome message."""
return {"message": "Hello, World!", "docs": "/docs"}
@app.get("/health")
async def health_check():
"""Health check endpoint for monitoring."""
return {"status": "healthy"}
@app.get("/items/{item_id}")
async def read_item(item_id: int):
"""Read a single item by its ID.
The item_id parameter is automatically parsed as an integer.
If a non-integer is provided, FastAPI returns a 422 error.
"""
return {"item_id": item_id, "name": f"Item {item_id}"}
@app.get("/search")
async def search_items(q: str, limit: int = 10, offset: int = 0):
"""Search for items with query parameters.
- q: required search query (string)
- limit: optional, defaults to 10
- offset: optional, defaults to 0
"""
return {
"query": q,
"limit": limit,
"offset": offset,
"results": [f"Result for '{q}' #{i}" for i in range(offset, offset + limit)]
}
Save the file, and Uvicorn will automatically reload. Try these URLs:
http://127.0.0.1:8000/ — Welcome messagehttp://127.0.0.1:8000/health — Health checkhttp://127.0.0.1:8000/items/42 — Item lookup (try /items/abc to see validation error)http://127.0.0.1:8000/search?q=python — Search with query parameterhttp://127.0.0.1:8000/search?q=python&limit=5&offset=10 — Search with all parameters--reload flag should only be used during development. In production, run Uvicorn without --reload and with multiple workers: uvicorn main:app --workers 4. We will cover production deployment in detail in the FastAPI – Deployment tutorial.
One of FastAPI’s most impressive features is automatic API documentation. Without writing a single line of documentation code, you already have two fully interactive documentation interfaces.
Navigate to http://127.0.0.1:8000/docs to access the Swagger UI documentation. This interface provides:
curl commandHere is what happens when you click “Try it out” on the /items/{item_id} endpoint:
item_id42)curl command equivalentNavigate to http://127.0.0.1:8000/redoc for the ReDoc documentation interface. ReDoc presents the same information in a three-panel layout:
ReDoc is often preferred for external-facing API documentation because it is cleaner and more readable. Many teams use Swagger UI for internal development and testing, and ReDoc for public documentation.
Both documentation interfaces are powered by the OpenAPI schema, which FastAPI generates automatically. You can access the raw schema at http://127.0.0.1:8000/openapi.json:
curl http://127.0.0.1:8000/openapi.json | python3 -m json.tool
This returns a JSON document following the OpenAPI 3.1 specification. This schema can be used for:
You can enrich the auto-generated documentation by adding metadata to your FastAPI instance and route handlers:
from fastapi import FastAPI
app = FastAPI(
title="Tutorial API",
description="""
## Tutorial API for learning FastAPI
This API demonstrates:
* **CRUD operations** on items
* **User management** with authentication
* **Search functionality** with pagination
### Authentication
Most endpoints require a valid JWT token in the Authorization header.
""",
version="2.0.0",
terms_of_service="https://example.com/terms",
contact={
"name": "API Support",
"url": "https://example.com/support",
"email": "support@example.com",
},
license_info={
"name": "MIT License",
"url": "https://opensource.org/licenses/MIT",
},
)
@app.get(
"/items/{item_id}",
summary="Get a single item",
description="Retrieve an item by its unique integer ID. Returns 404 if not found.",
response_description="The requested item",
tags=["Items"],
deprecated=False,
)
async def read_item(item_id: int):
return {"item_id": item_id}
The tags parameter groups endpoints in the documentation. The summary appears as the endpoint title, and description provides detailed explanation. You can also use docstrings on your handler functions — FastAPI will use them as the endpoint description if no explicit description parameter is provided.
app = FastAPI(docs_url=None, redoc_url=None). You can also change their URLs: app = FastAPI(docs_url="/api/docs", redoc_url="/api/redoc").
Type hints are the foundation of FastAPI. If you are not comfortable with Python type hints, this section will give you everything you need to know. If you are already experienced with type hints, you can skip to the next section.
Type hints (also called type annotations) were introduced in Python 3.5 via PEP 484. They allow you to declare the expected types of variables, function parameters, and return values. Python itself does not enforce these types at runtime — it is still a dynamically typed language. But tools like mypy, Pyright, and FastAPI use them extensively.
# Without type hints
def greet(name):
return f"Hello, {name}"
# With type hints
def greet(name: str) -> str:
return f"Hello, {name}"
The type-hinted version tells us (and our tools) that name should be a string and the function returns a string. The runtime behavior is identical.
# Primitive types
name: str = "FastAPI"
age: int = 30
price: float = 19.99
is_active: bool = True
# None type
result: None = None
# Function with type hints
def calculate_total(price: float, quantity: int, tax_rate: float = 0.08) -> float:
"""Calculate total price with tax."""
subtotal = price * quantity
return subtotal * (1 + tax_rate)
For collections, you specify both the container type and the type of elements it contains:
# Python 3.9+ syntax (recommended)
names: list[str] = ["Alice", "Bob", "Charlie"]
scores: dict[str, int] = {"Alice": 95, "Bob": 87}
unique_ids: set[int] = {1, 2, 3}
coordinates: tuple[float, float] = (40.7128, -74.0060)
# Python 3.7-3.8 syntax (use typing module)
from typing import List, Dict, Set, Tuple
names: List[str] = ["Alice", "Bob", "Charlie"]
scores: Dict[str, int] = {"Alice": 95, "Bob": 87}
unique_ids: Set[int] = {1, 2, 3}
coordinates: Tuple[float, float] = (40.7128, -74.0060)
list[str] instead of List[str]). FastAPI works with both syntaxes. We recommend the newer syntax if your project targets Python 3.9+.
# Python 3.10+ syntax
def find_user(user_id: int) -> dict | None:
"""Returns user dict or None if not found."""
...
def process_input(value: str | int) -> str:
"""Accepts either a string or integer."""
return str(value)
# Python 3.7-3.9 syntax
from typing import Optional, Union
def find_user(user_id: int) -> Optional[dict]:
"""Returns user dict or None if not found."""
...
def process_input(value: Union[str, int]) -> str:
"""Accepts either a string or integer."""
return str(value)
Optional[X] is equivalent to Union[X, None] or X | None. It means the value can be of type X or None.
In most Python code, type hints are informational — they help your IDE and static analysis tools but have no runtime effect. FastAPI changes this equation. FastAPI uses type hints at runtime to:
int, FastAPI converts the string from the URL to an integerint), FastAPI returns a 422 validation errorfrom fastapi import FastAPI
app = FastAPI()
@app.get("/users/{user_id}")
async def get_user(
user_id: int, # Path param: parsed as int, 422 if invalid
include_posts: bool = False, # Query param: parsed as bool, default False
limit: int = 10, # Query param: parsed as int, default 10
) -> dict: # Return type hint for documentation
"""
FastAPI uses these type hints to:
1. Extract user_id from the URL path and convert to int
2. Extract include_posts and limit from query string
3. Validate all types and return 422 on failure
4. Document all parameters with their types in /docs
"""
user = {"id": user_id, "name": f"User {user_id}"}
if include_posts:
user["posts"] = [{"title": f"Post {i}"} for i in range(limit)]
return user
With this single function definition, FastAPI knows that /users/abc is invalid (not an integer), /users/42?include_posts=true&limit=5 is valid, and the docs should show all three parameters with their types and defaults.
In FastAPI, a “path operation” is the combination of an HTTP method and a URL path. This is the fundamental building block of any API. Let us explore how FastAPI handles HTTP methods and how to use path operation decorators.
HTTP defines several request methods (also called “verbs”) that indicate the desired action to perform on a resource:
| Method | Purpose | Has Request Body? | Idempotent? | Safe? | FastAPI Decorator |
|---|---|---|---|---|---|
| GET | Read / retrieve a resource | No (typically) | Yes | Yes | @app.get() |
| POST | Create a new resource | Yes | No | No | @app.post() |
| PUT | Replace a resource entirely | Yes | Yes | No | @app.put() |
| PATCH | Partially update a resource | Yes | No | No | @app.patch() |
| DELETE | Remove a resource | No (typically) | Yes | No | @app.delete() |
| OPTIONS | Describe communication options | No | Yes | Yes | @app.options() |
| HEAD | GET without response body | No | Yes | Yes | @app.head() |
Here is a complete CRUD (Create, Read, Update, Delete) example for a “books” resource:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
# In-memory "database" for demonstration
books_db: dict[int, dict] = {}
next_id: int = 1
class BookCreate(BaseModel):
title: str
author: str
year: int
isbn: str | None = None
class BookUpdate(BaseModel):
title: str | None = None
author: str | None = None
year: int | None = None
isbn: str | None = None
# CREATE - POST
@app.post("/books", status_code=201)
async def create_book(book: BookCreate):
"""Create a new book."""
global next_id
book_data = book.model_dump()
book_data["id"] = next_id
books_db[next_id] = book_data
next_id += 1
return book_data
# READ ALL - GET
@app.get("/books")
async def list_books(skip: int = 0, limit: int = 20):
"""List all books with pagination."""
all_books = list(books_db.values())
return all_books[skip : skip + limit]
# READ ONE - GET
@app.get("/books/{book_id}")
async def get_book(book_id: int):
"""Get a single book by ID."""
if book_id not in books_db:
raise HTTPException(status_code=404, detail="Book not found")
return books_db[book_id]
# FULL UPDATE - PUT
@app.put("/books/{book_id}")
async def replace_book(book_id: int, book: BookCreate):
"""Replace a book entirely."""
if book_id not in books_db:
raise HTTPException(status_code=404, detail="Book not found")
book_data = book.model_dump()
book_data["id"] = book_id
books_db[book_id] = book_data
return book_data
# PARTIAL UPDATE - PATCH
@app.patch("/books/{book_id}")
async def update_book(book_id: int, book: BookUpdate):
"""Partially update a book."""
if book_id not in books_db:
raise HTTPException(status_code=404, detail="Book not found")
stored_book = books_db[book_id]
update_data = book.model_dump(exclude_unset=True)
stored_book.update(update_data)
return stored_book
# DELETE - DELETE
@app.delete("/books/{book_id}", status_code=204)
async def delete_book(book_id: int):
"""Delete a book."""
if book_id not in books_db:
raise HTTPException(status_code=404, detail="Book not found")
del books_db[book_id]
return None
Key patterns to notice:
status_code=201 on the POST handler — returns 201 Created instead of the default 200 OKstatus_code=204 on the DELETE handler — returns 204 No Content (standard for successful deletes)HTTPException — FastAPI’s built-in exception class for returning HTTP error responses. The status_code and detail are sent to the client as a JSON error responseBookCreate requires all fields; BookUpdate makes all fields optional for partial updatesmodel_dump(exclude_unset=True) — Only includes fields that were explicitly sent in the request, so PATCH works correctlyPath parameters are variables embedded in the URL path. FastAPI extracts them automatically:
from fastapi import FastAPI
from enum import Enum
app = FastAPI()
# Basic path parameter with type validation
@app.get("/users/{user_id}")
async def get_user(user_id: int):
return {"user_id": user_id}
# Multiple path parameters
@app.get("/organizations/{org_id}/teams/{team_id}")
async def get_team(org_id: int, team_id: int):
return {"org_id": org_id, "team_id": team_id}
# Enum path parameter - restricts to predefined values
class ModelName(str, Enum):
alexnet = "alexnet"
resnet = "resnet"
lenet = "lenet"
@app.get("/models/{model_name}")
async def get_model(model_name: ModelName):
# Only accepts "alexnet", "resnet", or "lenet"
# Returns 422 for any other value
return {"model": model_name, "message": f"Selected {model_name.value}"}
# File path parameter (captures the rest of the path)
@app.get("/files/{file_path:path}")
async def read_file(file_path: str):
# Matches /files/home/user/document.txt
return {"file_path": file_path}
Any function parameter that is not a path parameter is automatically treated as a query parameter:
from fastapi import FastAPI, Query
app = FastAPI()
# Basic query parameters
@app.get("/items")
async def list_items(
skip: int = 0, # Optional with default
limit: int = 10, # Optional with default
q: str | None = None # Optional, can be None
):
# URL: /items?skip=20&limit=5&q=search_term
return {"skip": skip, "limit": limit, "q": q}
# Required query parameter (no default value)
@app.get("/search")
async def search(q: str):
# URL: /search?q=python (q is required)
return {"query": q}
# Advanced query validation with Query()
@app.get("/products")
async def list_products(
q: str | None = Query(
default=None,
min_length=3,
max_length=50,
pattern=r"^[a-zA-Z0-9\s]+$",
title="Search query",
description="Search products by name. Minimum 3 characters.",
examples=["laptop", "wireless mouse"],
),
page: int = Query(default=1, ge=1, description="Page number (starting from 1)"),
size: int = Query(default=20, ge=1, le=100, description="Items per page (max 100)"),
sort_by: str = Query(default="name", pattern=r"^(name|price|rating)$"),
):
return {
"query": q,
"page": page,
"size": size,
"sort_by": sort_by,
}
The Query() function provides additional validation and metadata. The ge (greater than or equal), le (less than or equal), gt (greater than), lt (less than) constraints work for numeric parameters. The min_length, max_length, and pattern constraints work for string parameters. All of these constraints appear automatically in the generated documentation.
Understanding how FastAPI processes a request from arrival to response is essential for debugging, performance optimization, and middleware development. Here is the complete lifecycle of an HTTP request in a FastAPI application.
FastAPI is an ASGI application. ASGI (Asynchronous Server Gateway Interface) is the successor to WSGI, designed for async Python frameworks. The key differences:
| Feature | WSGI | ASGI |
|---|---|---|
| Concurrency Model | Synchronous (one request per thread) | Asynchronous (many requests per thread) |
| Protocol Support | HTTP only | HTTP, WebSocket, HTTP/2 |
| Long-lived Connections | Not supported | Native support |
| Background Tasks | Require external tools (Celery) | Built-in support |
| Frameworks | Flask, Django | FastAPI, Starlette, Django 3.0+ |
| Servers | Gunicorn, uWSGI | Uvicorn, Hypercorn, Daphne |
When a client sends an HTTP request to your FastAPI application, here is exactly what happens:
# Visualizing the request lifecycle
# Step 1: Client sends HTTP request
# GET /users/42?include_email=true HTTP/1.1
# Host: api.example.com
# Authorization: Bearer eyJhbGci...
# Step 2: Uvicorn (ASGI server) receives the raw TCP connection
# - Parses HTTP protocol
# - Creates ASGI scope dictionary
# - Passes to the ASGI application (FastAPI/Starlette)
# Step 3: Starlette middleware stack processes the request
# - CORS middleware
# - GZip middleware
# - Authentication middleware
# - Custom middleware
# (Each middleware can modify request/response or short-circuit)
# Step 4: Starlette router matches the URL path to a route
# - Finds the handler for GET /users/{user_id}
# - Extracts path parameters: user_id = "42"
# Step 5: FastAPI dependency injection
# - Resolves dependencies declared with Depends()
# - Runs authentication, database session creation, etc.
# Step 6: FastAPI request parsing and validation
# - Converts path params: user_id = int("42") = 42
# - Parses query params: include_email = bool("true") = True
# - Validates request body against Pydantic model (if applicable)
# - Returns 422 if validation fails
# Step 7: Your handler function executes
# async def get_user(user_id: int, include_email: bool = False):
# user = await db.get_user(user_id)
# return user
# Step 8: FastAPI response processing
# - Serializes return value to JSON
# - Applies response_model filtering (if defined)
# - Sets status code and headers
# - Validates response against response_model (in debug mode)
# Step 9: Middleware stack processes the response (in reverse order)
# - GZip compresses response body
# - CORS adds Access-Control headers
# Step 10: Uvicorn sends HTTP response to client
# HTTP/1.1 200 OK
# Content-Type: application/json
# {"id": 42, "name": "Alice", "email": "alice@example.com"}
Middleware is code that runs before every request and after every response. It wraps the entire application like layers of an onion. Here is how to add middleware in FastAPI:
import time
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
# Built-in CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "https://myapp.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Custom middleware: request timing
@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
"""Measure and log request processing time."""
start_time = time.perf_counter()
response = await call_next(request)
process_time = time.perf_counter() - start_time
response.headers["X-Process-Time"] = f"{process_time:.4f}"
print(f"{request.method} {request.url.path} - {process_time:.4f}s")
return response
# Custom middleware: request ID tracking
import uuid
@app.middleware("http")
async def add_request_id(request: Request, call_next):
"""Add a unique request ID to every request/response."""
request_id = str(uuid.uuid4())
request.state.request_id = request_id
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
@app.get("/")
async def root(request: Request):
return {
"message": "Hello",
"request_id": request.state.request_id
}
Middleware execution order matters. Middleware is applied in the order it is added, but wraps the application like nested layers:
FastAPI gives you access to the raw Starlette Request object when you need low-level control:
from fastapi import FastAPI, Request
app = FastAPI()
@app.get("/request-info")
async def request_info(request: Request):
"""Inspect the raw request object."""
return {
"method": request.method,
"url": str(request.url),
"base_url": str(request.base_url),
"path": request.url.path,
"query_params": dict(request.query_params),
"path_params": request.path_params,
"headers": dict(request.headers),
"client_host": request.client.host if request.client else None,
"client_port": request.client.port if request.client else None,
"cookies": request.cookies,
}
FastAPI supports multiple response types beyond simple JSON:
from fastapi import FastAPI
from fastapi.responses import (
JSONResponse,
HTMLResponse,
PlainTextResponse,
RedirectResponse,
StreamingResponse,
FileResponse,
)
app = FastAPI()
# Default: JSON response (automatic)
@app.get("/json")
async def json_response():
return {"message": "This is JSON"} # Automatically wrapped in JSONResponse
# Explicit JSON with custom status code and headers
@app.get("/custom-json")
async def custom_json():
return JSONResponse(
content={"message": "Custom response"},
status_code=201,
headers={"X-Custom-Header": "value"},
)
# HTML response
@app.get("/html", response_class=HTMLResponse)
async def html_response():
return "<h1>Hello from FastAPI</h1><p>This is HTML.</p>"
# Plain text response
@app.get("/text", response_class=PlainTextResponse)
async def text_response():
return "Just plain text"
# Redirect response
@app.get("/old-page")
async def redirect():
return RedirectResponse(url="/new-page", status_code=301)
# File download
@app.get("/download")
async def download_file():
return FileResponse(
path="report.pdf",
filename="monthly-report.pdf",
media_type="application/pdf",
)
# Streaming response (for large data)
@app.get("/stream")
async def stream_data():
async def generate():
for i in range(100):
yield f"data: chunk {i}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
A “Hello World” in a single file is fine for learning, but real applications need proper organization. Here is how professional FastAPI projects are structured.
For small APIs with fewer than 10 endpoints:
fastapi-project/
├── main.py # Application entry point, all routes
├── models.py # Pydantic models (schemas)
├── database.py # Database connection and session
├── requirements.txt # Dependencies
├── .env # Environment variables (never commit)
├── .gitignore
└── tests/
├── __init__.py
└── test_main.py
For APIs with 10-50 endpoints, organized by domain:
fastapi-project/ ├── app/ │ ├── __init__.py │ ├── main.py # Application factory, startup/shutdown events │ ├── config.py # Settings and configuration │ ├── dependencies.py # Shared dependencies (auth, db session) │ ├── database.py # Database engine and session setup │ ├── models/ # SQLAlchemy ORM models │ │ ├── __init__.py │ │ ├── user.py │ │ └── item.py │ ├── schemas/ # Pydantic request/response models │ │ ├── __init__.py │ │ ├── user.py │ │ └── item.py │ ├── routers/ # API route handlers (grouped by domain) │ │ ├── __init__.py │ │ ├── users.py │ │ └── items.py │ ├── services/ # Business logic layer │ │ ├── __init__.py │ │ ├── user_service.py │ │ └── item_service.py │ └── middleware/ # Custom middleware │ ├── __init__.py │ └── logging.py ├── tests/ │ ├── __init__.py │ ├── conftest.py # Shared fixtures │ ├── test_users.py │ └── test_items.py ├── alembic/ # Database migrations │ ├── env.py │ └── versions/ ├── alembic.ini ├── requirements.txt ├── .env └── .gitignore
For large applications with many domains and teams:
fastapi-project/ ├── app/ │ ├── __init__.py │ ├── main.py │ ├── core/ # Core application components │ │ ├── __init__.py │ │ ├── config.py # Pydantic Settings for configuration │ │ ├── security.py # JWT, password hashing, OAuth2 │ │ ├── database.py # Database engine, session, base model │ │ ├── dependencies.py # Shared dependencies │ │ └── exceptions.py # Custom exception handlers │ ├── features/ # Feature modules (domain-driven) │ │ ├── auth/ │ │ │ ├── __init__.py │ │ │ ├── router.py │ │ │ ├── schemas.py │ │ │ ├── models.py │ │ │ ├── service.py │ │ │ └── dependencies.py │ │ ├── users/ │ │ │ ├── __init__.py │ │ │ ├── router.py │ │ │ ├── schemas.py │ │ │ ├── models.py │ │ │ ├── service.py │ │ │ └── repository.py │ │ └── products/ │ │ ├── __init__.py │ │ ├── router.py │ │ ├── schemas.py │ │ ├── models.py │ │ ├── service.py │ │ └── repository.py │ └── common/ # Shared utilities │ ├── __init__.py │ ├── pagination.py │ ├── responses.py │ └── validators.py ├── tests/ ├── alembic/ ├── docker/ │ ├── Dockerfile │ └── docker-compose.yml ├── scripts/ │ ├── seed_db.py │ └── create_admin.py ├── pyproject.toml # Modern Python project config ├── requirements/ │ ├── base.txt │ ├── dev.txt │ └── prod.txt ├── .env.example └── .gitignore
The APIRouter is FastAPI’s way of organizing endpoints into separate modules. Think of it as a mini FastAPI application that you mount on your main app. Here is a practical example:
# app/routers/users.py
from fastapi import APIRouter, HTTPException, Depends
from app.schemas.user import UserCreate, UserResponse, UserUpdate
from app.services.user_service import UserService
from app.dependencies import get_db, get_current_user
router = APIRouter(
prefix="/users",
tags=["Users"],
responses={404: {"description": "User not found"}},
)
@router.get("/", response_model=list[UserResponse])
async def list_users(
skip: int = 0,
limit: int = 20,
db=Depends(get_db),
):
"""List all users with pagination."""
service = UserService(db)
return await service.get_users(skip=skip, limit=limit)
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(user_id: int, db=Depends(get_db)):
"""Get a single user by ID."""
service = UserService(db)
user = await service.get_user(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.post("/", response_model=UserResponse, status_code=201)
async def create_user(user: UserCreate, db=Depends(get_db)):
"""Create a new user."""
service = UserService(db)
return await service.create_user(user)
@router.put("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
user: UserUpdate,
db=Depends(get_db),
current_user=Depends(get_current_user),
):
"""Update a user. Requires authentication."""
service = UserService(db)
return await service.update_user(user_id, user)
@router.delete("/{user_id}", status_code=204)
async def delete_user(
user_id: int,
db=Depends(get_db),
current_user=Depends(get_current_user),
):
"""Delete a user. Requires authentication."""
service = UserService(db)
await service.delete_user(user_id)
# app/routers/items.py
from fastapi import APIRouter, Depends
from app.schemas.item import ItemCreate, ItemResponse
from app.dependencies import get_db
router = APIRouter(
prefix="/items",
tags=["Items"],
)
@router.get("/", response_model=list[ItemResponse])
async def list_items(db=Depends(get_db)):
"""List all items."""
...
@router.post("/", response_model=ItemResponse, status_code=201)
async def create_item(item: ItemCreate, db=Depends(get_db)):
"""Create a new item."""
...
# app/main.py
from fastapi import FastAPI
from app.routers import users, items
app = FastAPI(
title="My Application API",
version="1.0.0",
)
# Include routers
app.include_router(users.router)
app.include_router(items.router)
# You can also add a prefix when including
# app.include_router(users.router, prefix="/api/v1")
@app.get("/")
async def root():
return {"message": "Welcome to the API", "docs": "/docs"}
The result is a clean separation of concerns. Each router file handles a single domain (users, items, etc.), the main application file is short and declarative, and you can easily add new domains by creating a new router file and including it.
FastAPI projects should use Pydantic’s BaseSettings for configuration management. It reads from environment variables and .env files with full type validation:
# app/core/config.py
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# Application
app_name: str = "FastAPI Tutorial"
debug: bool = False
api_version: str = "v1"
# Server
host: str = "0.0.0.0"
port: int = 8000
workers: int = 4
# Database
database_url: str = "sqlite:///./app.db"
# Security
secret_key: str = "change-me-in-production"
access_token_expire_minutes: int = 30
algorithm: str = "HS256"
# CORS
allowed_origins: list[str] = ["http://localhost:3000"]
model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8",
"case_sensitive": False,
}
@lru_cache
def get_settings() -> Settings:
"""Cached settings instance. Call this instead of Settings() directly."""
return Settings()
# .env file APP_NAME=My Production API DEBUG=false DATABASE_URL=postgresql://user:pass@localhost:5432/mydb SECRET_KEY=your-super-secret-key-here ALLOWED_ORIGINS=["https://myapp.com","https://admin.myapp.com"]
# Using settings in your application
from fastapi import FastAPI, Depends
from app.core.config import Settings, get_settings
app = FastAPI()
@app.get("/info")
async def info(settings: Settings = Depends(get_settings)):
return {
"app_name": settings.app_name,
"debug": settings.debug,
"api_version": settings.api_version,
}
.env file to version control. Always add it to .gitignore. Create a .env.example file with placeholder values so other developers know which variables are needed.
FastAPI’s dependency injection system is one of its most powerful features. Dependencies are reusable functions that provide shared resources (database sessions, current user, settings) to your route handlers:
# app/dependencies.py
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from app.database import SessionLocal
# Database session dependency
def get_db():
"""Yield a database session, auto-close when done."""
db = SessionLocal()
try:
yield db
finally:
db.close()
# Authentication dependency
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db),
):
"""Validate JWT token and return current user."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(token)
user_id = payload.get("sub")
if user_id is None:
raise credentials_exception
except Exception:
raise credentials_exception
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise credentials_exception
return user
# Role-based access dependency
def require_role(required_role: str):
"""Factory that creates a dependency requiring a specific role."""
async def role_checker(current_user=Depends(get_current_user)):
if current_user.role != required_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Role '{required_role}' required",
)
return current_user
return role_checker
# Usage in routes:
# @router.delete("/users/{id}", dependencies=[Depends(require_role("admin"))])
# async def delete_user(id: int): ...
Dependencies can depend on other dependencies, forming a dependency tree. FastAPI resolves this tree automatically, creates instances in the correct order, and handles cleanup (via yield) when the request completes.
FastAPI supports startup and shutdown events using the modern lifespan context manager. This is where you initialize and clean up shared resources like database connection pools, HTTP clients, and caches:
from contextlib import asynccontextmanager
from fastapi import FastAPI
import httpx
# Shared resources
http_client: httpx.AsyncClient | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage application lifecycle - startup and shutdown."""
# --- STARTUP ---
global http_client
print("Starting up... initializing resources")
# Create a shared HTTP client with connection pooling
http_client = httpx.AsyncClient(
base_url="https://api.example.com",
timeout=30.0,
)
# Initialize database connection pool
# await database.connect()
# Load ML models, warm caches, etc.
print("Startup complete")
yield # Application runs here
# --- SHUTDOWN ---
print("Shutting down... cleaning up resources")
await http_client.aclose()
# await database.disconnect()
print("Shutdown complete")
app = FastAPI(lifespan=lifespan)
@app.get("/external-data")
async def get_external_data():
"""Use the shared HTTP client."""
response = await http_client.get("/data")
return response.json()
lifespan context manager replaces the older @app.on_event("startup") and @app.on_event("shutdown") decorators, which are deprecated. Always use lifespan for new projects.
Proper error handling is critical for production APIs. FastAPI provides several mechanisms for handling errors gracefully.
The simplest way to return an HTTP error response:
from fastapi import FastAPI, HTTPException, status
app = FastAPI()
@app.get("/items/{item_id}")
async def get_item(item_id: int):
if item_id < 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Item ID must be positive",
)
item = fake_db.get(item_id)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Item with ID {item_id} not found",
headers={"X-Error": "item-not-found"},
)
return item
For consistent error formatting across your entire API:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
app = FastAPI()
# Custom exception class
class AppException(Exception):
def __init__(self, status_code: int, detail: str, error_code: str):
self.status_code = status_code
self.detail = detail
self.error_code = error_code
# Handler for custom exceptions
@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException):
return JSONResponse(
status_code=exc.status_code,
content={
"error": {
"code": exc.error_code,
"message": exc.detail,
"path": str(request.url),
}
},
)
# Override default validation error handler for consistent format
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": exc.errors(),
}
},
)
# Override default HTTP exception handler
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
return JSONResponse(
status_code=exc.status_code,
content={
"error": {
"code": f"HTTP_{exc.status_code}",
"message": exc.detail,
}
},
)
# Usage
@app.get("/users/{user_id}")
async def get_user(user_id: int):
user = await find_user(user_id)
if not user:
raise AppException(
status_code=404,
detail=f"User {user_id} not found",
error_code="USER_NOT_FOUND",
)
return user
This pattern ensures every error response from your API has a consistent structure, making it easier for frontend developers and API consumers to handle errors programmatically.
While this is an introductory tutorial, it is worth understanding the basics of running FastAPI in production. We will cover this topic in depth in the FastAPI – Deployment tutorial later in this series.
| Setting | Development | Production |
|---|---|---|
| Server Command | uvicorn main:app --reload |
gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker |
| Workers | 1 (with reload) | 2-4x CPU cores |
| Auto-reload | Enabled | Disabled |
| Debug Mode | Enabled | Disabled |
| Docs | Enabled (/docs, /redoc) | Often disabled or restricted |
| HTTPS | Not needed | Required (via reverse proxy) |
| Logging | Console output | Structured JSON logging |
# Install Gunicorn (Linux/macOS only)
pip install gunicorn
# Run with Gunicorn + Uvicorn workers
gunicorn app.main:app \
--workers 4 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--access-logfile - \
--error-logfile -
# Dockerfile for FastAPI # app/Dockerfile FROM python:3.11-slim WORKDIR /app # Install dependencies COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Copy application code COPY . . # Run with Uvicorn (for single-container deployments) CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
# Build and run the Docker container docker build -t fastapi-app . docker run -p 8000:8000 fastapi-app
Let us build a complete, well-structured FastAPI application that demonstrates everything we have covered. This is a task management API with proper project organization:
# Project structure task-manager/ ├── app/ │ ├── __init__.py │ ├── main.py │ ├── config.py │ ├── models.py │ └── routers/ │ ├── __init__.py │ └── tasks.py ├── requirements.txt └── .env
# app/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "Task Manager API"
version: str = "1.0.0"
debug: bool = True
model_config = {"env_file": ".env"}
settings = Settings()
# app/models.py
from pydantic import BaseModel, Field
from datetime import datetime
from enum import Enum
class Priority(str, Enum):
low = "low"
medium = "medium"
high = "high"
critical = "critical"
class TaskStatus(str, Enum):
todo = "todo"
in_progress = "in_progress"
done = "done"
class TaskCreate(BaseModel):
"""Schema for creating a new task."""
title: str = Field(..., min_length=1, max_length=200, examples=["Write unit tests"])
description: str | None = Field(None, max_length=2000)
priority: Priority = Field(default=Priority.medium)
assignee: str | None = None
class TaskUpdate(BaseModel):
"""Schema for updating an existing task (all fields optional)."""
title: str | None = Field(None, min_length=1, max_length=200)
description: str | None = Field(None, max_length=2000)
priority: Priority | None = None
status: TaskStatus | None = None
assignee: str | None = None
class TaskResponse(BaseModel):
"""Schema for task responses."""
id: int
title: str
description: str | None
priority: Priority
status: TaskStatus
assignee: str | None
created_at: str
updated_at: str
# app/routers/tasks.py
from fastapi import APIRouter, HTTPException, Query
from app.models import TaskCreate, TaskUpdate, TaskResponse, TaskStatus, Priority
from datetime import datetime
router = APIRouter(prefix="/tasks", tags=["Tasks"])
# In-memory storage (use a real database in production)
tasks_db: dict[int, dict] = {}
next_id: int = 1
@router.get("/", response_model=list[TaskResponse])
async def list_tasks(
status: TaskStatus | None = Query(None, description="Filter by status"),
priority: Priority | None = Query(None, description="Filter by priority"),
assignee: str | None = Query(None, description="Filter by assignee"),
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
):
"""List all tasks with optional filters and pagination."""
results = list(tasks_db.values())
if status:
results = [t for t in results if t["status"] == status]
if priority:
results = [t for t in results if t["priority"] == priority]
if assignee:
results = [t for t in results if t["assignee"] == assignee]
return results[skip : skip + limit]
@router.get("/{task_id}", response_model=TaskResponse)
async def get_task(task_id: int):
"""Get a single task by ID."""
if task_id not in tasks_db:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return tasks_db[task_id]
@router.post("/", response_model=TaskResponse, status_code=201)
async def create_task(task: TaskCreate):
"""Create a new task."""
global next_id
now = datetime.utcnow().isoformat()
task_data = {
"id": next_id,
**task.model_dump(),
"status": TaskStatus.todo,
"created_at": now,
"updated_at": now,
}
tasks_db[next_id] = task_data
next_id += 1
return task_data
@router.patch("/{task_id}", response_model=TaskResponse)
async def update_task(task_id: int, task: TaskUpdate):
"""Partially update a task."""
if task_id not in tasks_db:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
stored = tasks_db[task_id]
update_data = task.model_dump(exclude_unset=True)
stored.update(update_data)
stored["updated_at"] = datetime.utcnow().isoformat()
return stored
@router.delete("/{task_id}", status_code=204)
async def delete_task(task_id: int):
"""Delete a task."""
if task_id not in tasks_db:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
del tasks_db[task_id]
@router.get("/stats/summary")
async def task_stats():
"""Get task statistics."""
tasks = list(tasks_db.values())
return {
"total": len(tasks),
"by_status": {
s.value: len([t for t in tasks if t["status"] == s])
for s in TaskStatus
},
"by_priority": {
p.value: len([t for t in tasks if t["priority"] == p])
for p in Priority
},
}
# app/main.py
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import tasks
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifecycle manager."""
print(f"Starting {settings.app_name} v{settings.version}")
yield
print("Shutting down...")
app = FastAPI(
title=settings.app_name,
version=settings.version,
description="A task management API built with FastAPI",
lifespan=lifespan,
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Request timing middleware
@app.middleware("http")
async def add_process_time(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
duration = time.perf_counter() - start
response.headers["X-Process-Time"] = f"{duration:.4f}"
return response
# Include routers
app.include_router(tasks.router)
@app.get("/", tags=["Health"])
async def root():
"""API root - health check and documentation links."""
return {
"app": settings.app_name,
"version": settings.version,
"status": "healthy",
"docs": "/docs",
"redoc": "/redoc",
}
@app.get("/health", tags=["Health"])
async def health():
"""Detailed health check."""
return {"status": "healthy", "version": settings.version}
# requirements.txt fastapi[standard]>=0.115.0 pydantic-settings>=2.0.0 uvicorn[standard]>=0.30.0
Run the application:
# From the project root
uvicorn app.main:app --reload
# Test the API
curl -X POST http://127.0.0.1:8000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Learn FastAPI", "priority": "high"}'
curl http://127.0.0.1:8000/tasks
curl http://127.0.0.1:8000/tasks?status=todo&priority=high
curl http://127.0.0.1:8000/tasks/stats/summary
curl http://127.0.0.1:8000/docs
This example demonstrates all the concepts from this tutorial: application setup, routers, Pydantic models, path parameters, query parameters, middleware, lifecycle events, error handling, and proper project structure.
Here is a summary of everything we covered in this introduction to FastAPI:
@app.get, @app.post, etc.) and a handler function. Path parameters, query parameters, and request bodies are all handled through function parameters and type hints.APIRouter to split your application into domain-specific modules. Each router handles a single concern and is included in the main application.Depends() system provides reusable, composable dependencies for database sessions, authentication, authorization, and any shared logic.HTTPException for simple errors and custom exception handlers for consistent error formatting across your entire API.In the next tutorial, FastAPI – Routes & Request Handling, we will dive deep into:
Query()