Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


FastAPI – Pydantic Models & Validation

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.

Prerequisites: You should have completed the previous tutorials in this FastAPI series (Introduction & Setup, Routes & Request Handling, REST API, and Database Integration). You need Python 3.10+ and FastAPI 0.100+ installed.
Source Code: All examples in this tutorial are self-contained. Create a project folder called fastapi-pydantic and follow along step by step.

1. What is Pydantic?

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.

1.1 Why FastAPI Chose Pydantic

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

1.2 Pydantic v2 Key Features

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

1.3 Installation

# 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

1.4 Your First Pydantic Model

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

2. Basic Models

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.

2.1 BaseModel Fundamentals

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

2.2 Field Types and Defaults

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

2.3 Required vs Optional Fields

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
Important: In Pydantic v2, 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.

2.4 Model Methods and Properties

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)

3. Field Validation

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.

3.1 Field() Basics

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)

3.2 All Numeric Constraints

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)

3.3 All String Constraints

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

3.4 Field Aliases and Metadata

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}

3.5 Pydantic Built-in Types

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/

4. Type Annotations Deep Dive

Pydantic leverages Python’s type annotation system extensively. Understanding how different type hints translate to validation rules is essential for building robust models.

4.1 Basic Types

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)

4.2 Collection Types

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'})

4.3 Optional, Union, and Literal

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)

4.4 Annotated Types

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
Tip: 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.

5. Nested Models

Real-world data is rarely flat. Pydantic excels at validating deeply nested structures by composing models together. Each nested model is fully validated independently.

5.1 Basic Nesting

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

5.2 Lists of Models

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

5.3 Self-Referential Models

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

6. Custom Validators

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.

6.1 @field_validator Basics

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)

6.2 Validator Modes: Before and After

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)

6.3 Validating Multiple Fields

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

6.4 @model_validator for Cross-Field Validation

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)

6.5 Before Mode Model Validator

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

6.6 ValidationError Details

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))

7. Computed Fields

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.

7.1 @computed_field

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

7.2 Computed Fields with Complex Logic

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

8. Model Configuration

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.

8.1 ConfigDict Basics

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

8.2 Extra Fields Handling

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'}

8.3 ORM Mode (from_attributes)

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

8.4 JSON Schema Customization

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

8.5 Frozen (Immutable) Models

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)

9. Serialization

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().

9.1 model_dump() Basics

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)

9.2 Nested Serialization Control

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'}}

9.3 JSON Serialization

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

9.4 Custom Serialization

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'

9.5 Alias-Based Serialization

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', ...}

10. Request Validation in FastAPI

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.

10.1 Request Body Validation

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"
  }'

10.2 Query Parameter Validation

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
        }
    }

10.3 Path Parameter Validation

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}

10.4 Header and Cookie Validation

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}

10.5 Multiple Body Parameters

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

11. Response Models

Response models control what data your API returns. They ensure sensitive fields are never leaked, provide consistent output structures, and generate accurate API documentation.

11.1 Basic Response Model

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())

11.2 Different Input and Output Schemas

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}
    ]

11.3 Response Model Options

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"}

12. Error Handling

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.

12.1 Default Validation Errors

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}
#         },
#         ...
#     ]
# }

12.2 Custom Error Response Format

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)
        }
    )

12.3 Custom HTTPException with Validation

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"}

12.4 Comprehensive Error Handling Setup

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))
    )

13. Generic Models

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.

13.1 Basic Generic Model

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())

13.2 Paginated Response

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
# }

13.3 Advanced Envelope Pattern

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)})

14. File and Form Data Validation

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.

14.1 Form Fields with Validation

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,
        }
    }

14.2 File Upload Validation

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}

14.3 Mixed Form Data and Files

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"

15. Complete Project: User Registration System

Let us bring everything together in a comprehensive user registration API with multi-step validation, address handling, password strength checking, and email verification.

15.1 Project Structure

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

15.2 Reusable Validators (models/validators.py)

# 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)]

15.3 Address Models (models/address.py)

# 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

15.4 User Models (models/user.py)

# 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

15.5 FastAPI Application (main.py)

# 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)

15.6 Testing the Registration System

# 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
Production Enhancement: In a real application, you would add: database persistence with SQLAlchemy, bcrypt password hashing, JWT authentication, rate limiting, email sending for verification, input sanitization, and CORS configuration. The Pydantic validation layer shown here forms the foundation that all of those features build upon.

16. Key Takeaways

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
Best Practices:

  • Always use extra="forbid" on input models to catch typos and unexpected fields
  • Create separate models for Create, Update, and Response operations
  • Use Annotated types for reusable validation logic across models
  • Put complex validation in @field_validator and cross-field checks in @model_validator
  • Never expose passwords or internal fields — always define a Response model
  • Use model_dump(exclude_unset=True) for PATCH endpoints to only update provided fields
  • Customize the validation error handler for a consistent API error format
  • Use from_attributes=True when working with SQLAlchemy ORM objects

In 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.

June 5, 2023

FastAPI – Database Integration

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.

Prerequisites: You should be comfortable with FastAPI basics, Pydantic models, and dependency injection from the previous tutorials in this series. Basic SQL knowledge is also helpful.
Source Code: All code examples in this tutorial are available as a complete working project. Each section builds on the previous one, culminating in a full e-commerce data layer.

1. Database Options for FastAPI

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:

1.1 SQLite

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.

1.2 PostgreSQL

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.

1.3 MySQL

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.

1.4 MongoDB

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.

1.5 Comparison Table

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
Recommendation: For this tutorial, we will use SQLAlchemy with SQLite for development and show PostgreSQL configurations for production. SQLAlchemy makes it easy to switch between databases by changing the connection string.

2. SQLAlchemy Setup

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.

2.1 Installing Dependencies

# 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

2.2 Project Structure

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

2.3 Database Configuration

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()

2.4 Engine Creation and Session Management

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)
Important: The 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.

2.5 Understanding the Engine

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"
    },
)

3. Database Models

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.

3.1 Defining a Basic Model

# 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}')>"

3.2 Column Types Reference

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

3.3 One-to-Many Relationships

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}')>"

3.4 Many-to-Many Relationships

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}')>"

3.5 Many-to-Many with Extra Data (Association Object)

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")

3.6 Indexes and Constraints

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}')>"

4. Database Session Dependency

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.

4.1 Basic Session Dependency

# 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)]

4.2 Session with Automatic Commit/Rollback

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)]
Note: The auto-commit approach above is convenient but gives you less control. Many developers prefer to call db.commit() explicitly in their route handlers or service layer. Choose the approach that fits your team’s conventions.

4.3 Using the Dependency in Routes

# 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

4.4 Request-Scoped Sessions Explained

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

5. CRUD Operations with SQLAlchemy

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.

5.1 Create Operations

# 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"
        )

5.2 Read Operations

# 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}

5.3 Update Operations

# 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}

5.4 Delete Operations

# 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}

6. Pydantic Schemas vs DB Models

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.

6.1 Why Separate Schemas from Models?

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

6.2 Schema Organization

# 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)

6.3 Product Schemas

# 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)

6.4 The from_attributes Pattern (formerly orm_mode)

# 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

6.5 Paginated Response Schema

# 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,
    )

7. Alembic Migrations

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.”

7.1 Setting Up Alembic

# 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

7.2 Configuring Alembic

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()

7.3 Creating Migrations

# 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

7.4 Auto-Generated Migration Example

# 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")

7.5 Applying and Reverting Migrations

# 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

7.6 Data Migrations

# 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)")

7.7 Migration Best Practices

Migration Best Practices:

  • Never edit an applied migration — always create a new one to fix issues
  • Test migrations both ways — verify both upgrade() and downgrade()
  • Use descriptive messages"add email index to users" not "update"
  • Review auto-generated migrations — they are not always perfect
  • Include data migrations when schema changes affect existing data
  • Keep migrations small — one logical change per migration
  • Commit migrations with code changes — they belong together in version control
  • Never use create_all() in production — always use Alembic migrations

8. Async Database Access

FastAPI 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.

8.1 SQLAlchemy 2.0 Async Setup

# 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)

8.2 Async Session Dependency

# 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)]

8.3 Async CRUD Operations

# 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)

8.4 When to Use Async

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
Important: Do not mix sync and async carelessly. If your route is 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.

9. Connection Pooling

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.

9.1 How Connection Pooling Works

"""
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)          │
│  └──────┘                                │
└─────────────────────────────────────────┘
"""

9.2 Pool Configuration

# 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.
)

9.3 Monitoring Pool Health

# 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}")

9.4 Pool Sizing Guidelines

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
Production Tip: For PostgreSQL at scale, consider using PgBouncer as an external connection pooler. It sits between your application and PostgreSQL, managing connections more efficiently than application-level pooling alone. With PgBouncer, you can use NullPool in SQLAlchemy and let PgBouncer handle all pooling.

10. Relationships and Joins

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.

10.1 The N+1 Problem

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!

10.2 Eager Loading with joinedload

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!

10.3 Eager Loading with selectinload

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)

10.4 Comparison of Loading Strategies

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

10.5 Practical Join Examples

# 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
    ]

10.6 Preventing N+1 with raiseload

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

11. Transactions

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.

11.1 Basic Transaction Management

# 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

11.2 Explicit Transaction Control

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

11.3 Nested Transactions (Savepoints)

@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

11.4 Transaction Patterns and Best Practices

# 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
Transaction Tips:

  • Keep transactions short — long-running transactions hold locks and reduce concurrency
  • Use with_for_update() for rows that need pessimistic locking (e.g., account balances)
  • Use savepoints (begin_nested()) when partial failures are acceptable
  • Always handle IntegrityError for unique constraint violations
  • Use db.flush() to get generated IDs without committing the transaction

12. Repository Pattern

The 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.

12.1 Why Use the Repository Pattern?

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

12.2 Generic Base Repository

# 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

12.3 Model-Specific Repository

# 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)

12.4 Product Repository

# 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()
        )

12.5 Dependency Injection with Repositories

# 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)]

12.6 Using Repositories in Routes

# 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()

13. Complete Project: E-Commerce Data Layer

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.

13.1 Complete Project Structure

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

13.2 Application Entry Point

# 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(),
        },
    }

13.3 Complete Models Package

# 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}')>"

13.4 Complete Order Schemas

# 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)

13.5 Order Repository

# 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},
        }

13.6 Order Routes

# 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()

13.7 Running the Application

# 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

13.8 Testing the API

# 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

14. Key Takeaways

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.
What’s Next? In the next tutorial, we will dive into FastAPI Pydantic Models & Validation, where we will explore advanced validation techniques, custom validators, nested models, and how to build robust data validation layers for your API.
June 4, 2023

FastAPI – REST API

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.

Prerequisites: You should have FastAPI installed and understand basic routing and Pydantic models. If you are new to FastAPI, start with our FastAPI – Introduction & Setup and FastAPI – Routes & Request Handling tutorials first.
Installation: Make sure you have the required packages installed:

pip install fastapi uvicorn sqlalchemy pydantic[email] python-multipart

1. REST API Principles

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.

1.1 Core REST Constraints

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

1.2 Resources and URIs

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
Anti-patterns to avoid:

  • Using verbs in URIs: /api/getBooks — use /api/books with GET method instead
  • Using singular nouns: /api/book — use /api/books (collections are plural)
  • Deep nesting beyond 2 levels: /api/v1/users/5/orders/10/items/3/reviews — flatten it
  • Using action names: /api/books/delete/42 — use DELETE /api/books/42 instead

1.3 HTTP Methods and Their Semantics

REST 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.

1.4 REST in FastAPI

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.

2. Pydantic Models for API

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.

2.1 Request vs Response Schemas

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.

2.2 Nested Models

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.

2.3 Field Validation and Custom Validators

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

2.4 Computed Fields and Serialization

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

2.5 Model Configuration and JSON Schema

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.

3. CRUD Operations

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.

3.1 Project Setup

# 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)

3.2 In-Memory Data Store

# 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()

3.3 Create — POST Endpoint

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:

  • Status 201: We return 201 Created (not 200 OK) to indicate a new resource was created
  • Conflict detection: We check for duplicate ISBNs and return 409 Conflict if one exists
  • Validation: Pydantic automatically validates all input fields before our code runs
  • Response model: The response_model=BookResponse ensures the response matches our schema

3.4 Read — GET Endpoints

from 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

3.5 Update — PUT and PATCH Endpoints

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
exclude_unset=True is critical: This ensures that only fields the client explicitly included in the request body are used for the update. Fields set to None vs fields not provided at all are handled differently — model_dump(exclude_unset=True) only returns fields the client actually sent.

3.6 Delete — DELETE Endpoint

@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)

3.7 Bulk Operations

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.

4. Response Models

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.

4.1 Basic response_model Usage

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.

4.2 response_model_exclude and response_model_include

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]

4.3 Multiple Response Models

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)

4.4 Generic Wrapper Responses

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.

5. Status Codes & Error Handling

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.

5.1 HTTP Status Code Reference

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

5.2 HTTPException

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.

5.3 Custom Exception Classes

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())

5.4 Overriding Default Validation Error Handler

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",
        },
    )
Security note: Never expose internal error details, stack traces, or database error messages to clients in production. Log them server-side and return a generic message to the client.

5.5 Error Responses in OpenAPI Documentation

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

6. Pagination

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.

6.1 Offset/Limit Pagination

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)

6.2 Pagination as a Dependency

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,
    }

6.3 Cursor-Based Pagination

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,
    )

6.4 Link Headers for Pagination

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

7. Filtering & Sorting

Clients need to find specific subsets of data efficiently. Well-designed filtering and sorting makes your API dramatically more useful.

7.1 Basic Query Parameter Filters

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,
        },
    }

7.2 Filter Dependency Pattern

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.

7.3 Multi-Value Filters

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)}

8. API Versioning

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.

8.1 URL Path Versioning

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)

8.2 Header-Based Versioning

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",
        )

8.3 Organized Project Structure for Versioning

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
Recommendation: Use URL path versioning for most projects. It is the most explicit, easiest to test, and best supported by API gateways and documentation tools.

9. Background Tasks

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.

9.1 Basic Background Tasks

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

9.2 Background Tasks with Dependencies

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)

9.3 Report Generation Example

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]
When to use BackgroundTasks vs a task queue:

  • BackgroundTasks: Quick operations (seconds), non-critical tasks (logging, notifications). Runs in the same process.
  • Celery/Redis Queue: Long-running tasks (minutes+), critical tasks that must not be lost, tasks needing retries. Runs in separate worker processes.

For production systems with heavy background processing, use Celery with Redis or RabbitMQ as the broker.

10. Middleware

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.

10.1 Custom Middleware

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)

10.2 CORS Middleware

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,
)
Security warning: Never use 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.

10.3 Rate Limiting Middleware

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)

10.4 Function-Based Middleware

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)

11. OpenAPI Customization

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.

11.1 Application-Level Configuration

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",
)

11.2 Tags for Grouping Endpoints

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)

11.3 Request and Response Examples

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())

11.4 Deprecating Endpoints

@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()]

11.5 Hiding Internal Endpoints

# 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,
    }

12. Complete Project: Book Management API

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.

12.1 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

12.2 Configuration

# 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()

12.3 Custom Exceptions

# 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,
            },
        )

12.4 Pydantic Models

# 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)

12.5 Data Store

# 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)

12.6 Dependencies

# 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,
        )

12.7 Book Router

# 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}

12.8 Main Application

# 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

12.9 Dockerfile

# 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"]

12.10 Requirements

# 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

12.11 Testing the API

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.

13. Key Takeaways

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:

REST Design Principles

  • Use nouns, not verbs in your URIs — /books not /getBooks
  • Map HTTP methods to CRUD — GET reads, POST creates, PUT replaces, PATCH updates, DELETE removes
  • Return appropriate status codes — 201 for creation, 204 for deletion, 404 for not found, 409 for conflicts
  • Design for statelessness — each request must contain all information needed to process it
  • Version your API from day one — it is much easier to start with /api/v1/ than to add it later

Pydantic Models

  • Separate schemas by purpose — use distinct Create, Update, and Response models
  • Never expose sensitive data — use response_model to filter passwords, API keys, and internal fields
  • Validate at the boundary — use field constraints, custom validators, and model validators to reject bad data early
  • Use exclude_unset=True for PATCH endpoints to distinguish between “not provided” and “set to null”
  • Add JSON schema examples to improve API documentation

Error Handling

  • Use custom exception classes for consistent, structured error responses
  • Override the default validation handler to match your API’s error format
  • Never expose internal details — log stack traces server-side, return generic messages to clients
  • Document error responses in your OpenAPI spec using the responses parameter

Pagination, Filtering & Sorting

  • Always paginate collections — never return unbounded lists
  • Use cursor-based pagination for large or frequently-updated datasets
  • Extract filters into dependency classes to keep endpoints clean and reusable
  • Support multi-value filters (?genres=fiction&genres=science) for flexible querying

Production Patterns

  • Use APIRouter to organize endpoints into modular, testable groups
  • Use BackgroundTasks for non-blocking operations (email, logging, notifications)
  • Add middleware for cross-cutting concerns — timing, request IDs, security headers, rate limiting
  • Configure CORS properly — never use allow_origins=["*"] with credentials in production
  • Customize OpenAPI documentation with tags, examples, and descriptions to make your API self-documenting

Quick Reference: Common Endpoint Patterns

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.

June 3, 2023

FastAPI – Routes & Request Handling

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.

Prerequisites: You should have FastAPI installed and understand basic Python type hints. If you are new to FastAPI, start with our FastAPI – Introduction & Setup tutorial first.
Installation: Make sure you have FastAPI and Uvicorn installed:

pip install fastapi uvicorn python-multipart aiofiles

python-multipart is needed for form data and file uploads. aiofiles is needed for FileResponse.

1. Route Basics

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.

1.1 The 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"
)

1.2 Path Operation Decorators

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

1.3 Async vs Sync Handlers

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()
Important: If you declare a handler with 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.

1.4 Route Order Matters

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.

1.5 Multiple Decorators on One Function

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"}

1.6 Running the Application

# 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)

2. Path Parameters

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.

2.1 Basic 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"
        }
    ]
}

2.2 Type Annotations for Path Parameters

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

2.3 Enum Path Parameters

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]
    }

2.4 Path() for Validation and Metadata

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}
Note: The ... (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.

2.5 Multiple Path Parameters

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)
    }

3. Query Parameters

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.

3.1 Basic Query Parameters

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
    }

3.2 Required vs Optional Query Parameters

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
Boolean Conversion: FastAPI automatically converts common boolean representations. All of the following are treated as True: true, True, 1, yes, on. And these as False: false, False, 0, no, off.

3.3 Query() for Advanced Validation

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,
    }

3.4 Multiple Values for a Query Parameter (Lists)

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]}

3.5 Deprecating Query Parameters

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}

3.6 Alias for Query Parameters

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}

4. Request Headers

HTTP headers carry metadata about the request. FastAPI provides the Header() function to extract and validate header values with full type support.

4.1 Basic Header Extraction

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,
    }
Header Name Conversion: FastAPI automatically converts Python-style 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).

4.2 Required Headers and Validation

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}

4.3 Duplicate Headers (Multiple Values)

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}

4.4 Common Header Patterns

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"
    }

5. Request Body

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.

5.1 Basic Request Body with Pydantic

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

5.2 Nested Models

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()
    }

5.3 Multiple Body Parameters

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
    }

5.4 Embedding a Single Body Parameter

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

5.5 Raw Body Access

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
    }

6. Cookie Parameters

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"}
Testing Cookies: When testing with cURL, pass cookies with the -b flag:

curl -b "session_id=abc123;tracking_id=xyz789" http://localhost:8000/items

7. Form Data and File Uploads

For HTML forms and file uploads, FastAPI provides the Form() and File()/UploadFile types. These require the python-multipart package.

7.1 Form Data

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
    }

7.2 File Uploads

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}
File Upload Testing: Use cURL with -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"

8. Response Handling

While FastAPI defaults to returning JSON responses, it supports multiple response types for different use cases.

8.1 JSONResponse (Default)

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))
    )

8.2 HTMLResponse

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>
    """

8.3 RedirectResponse

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")

8.4 StreamingResponse

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",
        }
    )

8.5 FileResponse

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
    )

8.6 PlainTextResponse and Custom Responses

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"
    )

9. Status Codes

Proper HTTP status codes are essential for RESTful APIs. FastAPI provides the status module with named constants for all standard HTTP status codes.

9.1 Setting 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": []}

9.2 Common Status Code Constants

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

9.3 Dynamic Status Codes with Response Parameter

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]}

9.4 HTTPException for Error Responses

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"}

10. Response Headers and Cookies

10.1 Setting Response Headers

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)

10.2 Cookie Management

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"}
Cookie Security Best Practices:

  • Always set httponly=True for session cookies to prevent XSS attacks
  • Set secure=True in production to ensure cookies are only sent over HTTPS
  • Use samesite="lax" or samesite="strict" for CSRF protection
  • Set reasonable max_age values — do not use indefinite cookies for sessions

11. APIRouter — Organizing Routes

As 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.

11.1 Project Structure

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

11.2 Creating Routers

# 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]

11.3 Including Routers in the Main Application

# 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"}

11.4 Nested Routers

# 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"}

11.5 Router with Shared Dependencies

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"]}

12. Route Dependencies

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.

12.1 Basic Dependencies with Depends()

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
    }

12.2 Class-Based Dependencies

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,
    }

12.3 Dependency Chains

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"]}

12.4 Dependencies with Yield (Context Managers)

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}

12.5 Application-Level Dependencies

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"}

13. The Request Object

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"
    }

14. Practical Example — URL Shortener Service

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.

14.1 Project Structure

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

14.2 Models

# 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"
    )

14.3 Database Module

# 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()

14.4 Dependencies

# 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", ""),
    }

14.5 URL Routes

# 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")

14.6 Analytics Routes

# 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"
        },
    )

14.7 Admin Routes

# 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,
    }

14.8 Main Application

# 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"),
        },
    )

14.9 Testing the URL Shortener

# 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"

14.10 Requirements File

# requirements.txt
fastapi==0.115.0
uvicorn[standard]==0.30.0
python-multipart==0.0.9
aiofiles==24.1.0
httpx==0.27.0

15. Key Takeaways

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.
What is Next: In the next tutorial, FastAPI – REST API, we will build a complete RESTful API with CRUD operations, response models, error handling patterns, and API versioning strategies.
June 2, 2023

FastAPI – Introduction & Setup

Introduction

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:

  • Starlette — A lightweight ASGI (Asynchronous Server Gateway Interface) framework that provides the core web functionality: routing, request/response handling, WebSocket support, middleware, and background tasks. Starlette gives FastAPI its exceptional performance characteristics.
  • Pydantic — A data validation and serialization library that uses Python type annotations to define data schemas. Pydantic powers FastAPI’s automatic request validation, response serialization, and documentation generation. All without writing a single line of validation code manually.

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.


Why 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.

1. Blazing Fast Performance

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
Note: Benchmarks vary by hardware, payload size, and test methodology. The numbers above represent typical results from the TechEmpower Framework Benchmarks and similar independent tests. The key takeaway is that FastAPI is in the same performance league as Node.js and not far behind compiled languages like Go.

The performance advantage over Flask and Django comes from two architectural decisions:

  • ASGI vs WSGI — FastAPI uses the Asynchronous Server Gateway Interface, which supports non-blocking I/O natively. When your API waits for a database query, file read, or external API call, the server can handle other requests simultaneously. WSGI frameworks like Flask block the entire worker process during I/O operations.
  • Uvicorn’s event loop — Uvicorn uses 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.

2. Automatic Interactive Documentation

FastAPI automatically generates interactive API documentation from your code. You get two documentation interfaces out of the box, with zero additional configuration:

  • Swagger UI (available at /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 (available at /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.

3. Type Safety and Automatic Validation

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.

4. Native Async Support

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.

Tip: FastAPI also works perfectly well with regular synchronous functions. If your handler does not need async, just define it with 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.

5. Developer Productivity

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:

  • Request body parsing and validation
  • Query parameter parsing and validation
  • Path parameter parsing and type conversion
  • Response serialization and filtering
  • OpenAPI schema generation
  • Interactive documentation
  • Editor autocompletion and type checking

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.


FastAPI vs Flask vs Django

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

When to Choose FastAPI

  • You are building a REST or GraphQL API. FastAPI was purpose-built for APIs. Automatic validation, serialization, and documentation make it the most productive choice for API development.
  • Performance matters. If your application handles high concurrency or real-time data (WebSockets, server-sent events, streaming), FastAPI’s async-first architecture delivers throughput that Flask and Django cannot match without significant architectural changes.
  • You value type safety. If you prefer catching errors at development time rather than in production, FastAPI’s type-driven approach is ideal. Your IDE becomes a powerful validation tool.
  • You need modern async features. Background tasks, WebSocket connections, streaming responses, and concurrent external API calls are natural in FastAPI.
  • Your team already uses type hints. If your Python codebase already uses type annotations, FastAPI will feel like a natural extension of how you write code.

When to Choose Flask

  • You need maximum flexibility. Flask’s unopinionated nature lets you structure your project however you want. There is no prescribed way to do anything.
  • You are prototyping quickly. Flask’s simplicity means you can go from idea to working prototype in minutes. A “Hello World” is 5 lines of code.
  • You are building a small web application with templates. If your application serves HTML pages with minimal API surface, Flask’s Jinja2 integration is excellent.
  • You need a mature ecosystem. Flask has been around since 2010 and has extensions for virtually everything. Whatever you need, someone has probably built a Flask extension for it.

When to Choose Django

  • You are building a full-stack web application. Django’s “batteries included” approach (ORM, admin panel, authentication, forms, migrations) is unmatched for traditional web applications.
  • You need a built-in admin interface. Django’s admin panel is one of the most powerful features in any web framework. For content management, back-office tools, and data administration, nothing comes close.
  • You want convention over configuration. Django makes many decisions for you. In a large team, this consistency is valuable.
  • You are building an e-commerce site or CMS. Django powers sites like Instagram, Pinterest, and Mozilla. Its ecosystem for content-heavy applications is the most mature in Python.
Key Insight: FastAPI, Flask, and Django are not competitors — they solve different problems. Many production systems use FastAPI for high-performance API services alongside Django for admin interfaces and content management. Choose based on your specific requirements, not framework popularity.

Installation & Setup

Prerequisites

Before installing FastAPI, ensure you have the following:

  • Python 3.7 or higher — FastAPI requires Python 3.7+ because it relies on type hints and async/await syntax. We recommend Python 3.10+ for the best experience (better error messages, structural pattern matching, and improved type hint syntax).
  • pip — Python’s package installer (included with Python 3.4+).
  • A virtual environment — Always use a virtual environment for Python projects. If you are unfamiliar with virtual environments, review our Python Advanced – Virtual Environments & pip tutorial.

Check your Python version:

python3 --version
# Python 3.11.6 (or higher)

Creating a Virtual Environment

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

Installing FastAPI and Uvicorn

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:

  • fastapi — The framework itself
  • uvicorn[standard] — The ASGI server with high-performance extras (uvloop, httptools)
  • pydantic — Data validation (core dependency)
  • starlette — The ASGI toolkit (core dependency)
  • python-multipart — Required for form data parsing
  • email-validator — Email validation support for Pydantic
  • httpx — Async HTTP client (for testing)
  • jinja2 — Template engine (optional, for HTML responses)
Tip: If you want a minimal installation, you can install just the core: 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

Freezing Dependencies

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

Your First FastAPI Application

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:

  1. from fastapi import FastAPI — Import the FastAPI class from the framework.
  2. 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.
  3. @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.
  4. 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 /.
  5. 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.

Running the Application

Start the development server with Uvicorn:

uvicorn main:app --reload

Let us break down this command:

  • main — The Python module (file) name, main.py
  • app — 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.

Testing Your Endpoint

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!"}

Adding More Endpoints

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 message
  • http://127.0.0.1:8000/health — Health check
  • http://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 parameter
  • http://127.0.0.1:8000/search?q=python&limit=5&offset=10 — Search with all parameters
Important: The --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.

Automatic Interactive Documentation

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.

Swagger UI (/docs)

Navigate to http://127.0.0.1:8000/docs to access the Swagger UI documentation. This interface provides:

  • A list of all endpoints grouped by tags, showing the HTTP method, path, and summary
  • Request parameter details — path parameters, query parameters, request bodies, with their types and constraints
  • Response schemas — the structure and types of response data
  • “Try it out” functionality — click any endpoint, fill in parameters, click “Execute,” and see the actual response from your running server
  • cURL commands — after executing a request, Swagger UI shows the equivalent curl command

Here is what happens when you click “Try it out” on the /items/{item_id} endpoint:

  1. Swagger UI shows an input field for item_id
  2. You enter a value (e.g., 42)
  3. Click “Execute”
  4. Swagger UI sends a real HTTP request to your server
  5. The response appears below, along with the response code, headers, and the curl command equivalent

ReDoc (/redoc)

Navigate to http://127.0.0.1:8000/redoc for the ReDoc documentation interface. ReDoc presents the same information in a three-panel layout:

  • Left panel — Navigation menu with all endpoints
  • Center panel — Endpoint details, parameters, and descriptions
  • Right panel — Request/response examples and code snippets

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.

The OpenAPI Schema

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:

  • Code generation — Automatically generate client SDKs in TypeScript, Java, Go, or any language using tools like openapi-generator
  • API testing — Import the schema into Postman, Insomnia, or other API testing tools
  • Contract testing — Validate that your API conforms to its specification
  • API gateways — Configure AWS API Gateway, Kong, or Apigee from the schema

Customizing Documentation

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.

Tip: You can disable the documentation endpoints in production if needed: app = FastAPI(docs_url=None, redoc_url=None). You can also change their URLs: app = FastAPI(docs_url="/api/docs", redoc_url="/api/redoc").

Python Type Hints Primer

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.

What Are Type Hints?

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.

Basic Types

# 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)

Collection Types

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)
Note: Starting with Python 3.9, you can use built-in types directly (list[str] instead of List[str]). FastAPI works with both syntaxes. We recommend the newer syntax if your project targets Python 3.9+.

Optional and Union Types

# 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.

Why Type Hints Matter in FastAPI

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:

  1. Parse request data — If you declare a path parameter as int, FastAPI converts the string from the URL to an integer
  2. Validate input — If conversion fails (e.g., “abc” cannot become an int), FastAPI returns a 422 validation error
  3. Generate documentation — The OpenAPI schema reflects your type hints, showing exact parameter types and constraints
  4. Serialize responses — Response models filter and format output based on type annotations
from 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.


Path Operations

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 Methods Overview

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()
Terminology: Idempotent means making the same request multiple times has the same effect as making it once. Safe means the request does not modify the resource. GET is both safe and idempotent. POST is neither — posting twice creates two resources. PUT is idempotent but not safe — it modifies the resource, but doing it twice has the same result as doing it once.

Path Operation Decorators in Practice

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 OK
  • status_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 response
  • Separate models for create vs updateBookCreate requires all fields; BookUpdate makes all fields optional for partial updates
  • model_dump(exclude_unset=True) — Only includes fields that were explicitly sent in the request, so PATCH works correctly

Path Parameters

Path 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}

Query Parameters

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.


Request & Response Cycle

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.

The ASGI Standard

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

Request Lifecycle Step by Step

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

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:

  1. Request arrives → first middleware added runs first
  2. Request passes through all middleware to the handler
  3. Response returns → last middleware added runs first (reverse order)

The Request Object

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,
    }

Response Types

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")

Project Structure Best Practices

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.

Small Project (Single Module)

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

Medium Project (Package with Routers)

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

Large Project (Domain-Driven Design)

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

Implementing Routers

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.

Configuration with Pydantic Settings

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,
    }
Security Warning: Never commit your .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.

Dependency Injection

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.


Application Lifecycle Events

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()
Note: The 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.

Error Handling

Proper error handling is critical for production APIs. FastAPI provides several mechanisms for handling errors gracefully.

HTTPException

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

Custom Exception Handlers

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.


Running in Production

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.

Development vs Production

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

Quick Production Setup

# 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

Complete Example: Putting It All Together

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.


Key Takeaways

Here is a summary of everything we covered in this introduction to FastAPI:

  1. FastAPI is built on Starlette and Pydantic. Starlette provides the high-performance ASGI foundation, and Pydantic provides automatic data validation and serialization. Understanding these two libraries helps you understand FastAPI at a deeper level.
  2. Performance is exceptional. FastAPI’s async-first architecture and Uvicorn’s event loop deliver throughput comparable to Node.js and Go. For I/O-bound workloads (database queries, external API calls), FastAPI significantly outperforms Flask and Django.
  3. Type hints drive everything. FastAPI uses Python type hints not just for documentation, but for request parsing, data validation, response serialization, and API documentation generation. Learning type hints well is the most valuable investment you can make when working with FastAPI.
  4. Documentation is automatic. Swagger UI and ReDoc are generated from your code with zero configuration. Every parameter, model, and constraint you define appears in the documentation immediately. Your docs never drift out of sync with your code.
  5. Path operations are the building blocks. Every endpoint is defined by an HTTP method decorator (@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.
  6. Routers enable modular organization. Use APIRouter to split your application into domain-specific modules. Each router handles a single concern and is included in the main application.
  7. Dependency injection is powerful. FastAPI’s Depends() system provides reusable, composable dependencies for database sessions, authentication, authorization, and any shared logic.
  8. Error handling should be consistent. Use HTTPException for simple errors and custom exception handlers for consistent error formatting across your entire API.
  9. Project structure matters. Start simple and evolve. A single-file application is fine for learning, but production applications should use the router-based structure with separate layers for schemas, services, and data access.
  10. ASGI is the future of Python web development. ASGI supports async I/O, WebSockets, HTTP/2, and long-lived connections. If you are starting a new Python web project today, ASGI frameworks like FastAPI are the modern choice.

What is Next

In the next tutorial, FastAPI – Routes & Request Handling, we will dive deep into:

  • Advanced path parameters and path converters
  • Query parameter validation with Query()
  • Request body handling with Pydantic models
  • Headers, cookies, and form data
  • File uploads (single and multiple)
  • Request validation and custom validators
  • Response models and status codes

References

June 1, 2023