Skip to main content

Building Production APIs with FastAPI: Pydantic, Dependency Injection, and Deployment

Table of Contents
FastAPI combines Python type hints with automatic OpenAPI documentation and near-Go performance. It has become the default choice for Python APIs. This post covers the patterns that matter in production: Pydantic models, dependency injection, background tasks, and multi-worker deployment.

FastAPI is not a thin wrapper around Starlette. The combination of Pydantic v2 for validation, Depends() for dependency injection, and async-native request handling makes it the closest Python equivalent to a typed HTTP framework. The original version of this post had a broken handler signature and no production content. This is the rebuild.

Installation
#

pip install fastapi "uvicorn[standard]"

The [standard] extra installs uvloop (faster event loop), httptools (faster HTTP parsing), and websockets. Always use it.

Pydantic Models: Request, Response, and Validation
#

models.py
from datetime import datetime
from typing import Annotated
from pydantic import BaseModel, Field, field_validator


class CreateUserRequest(BaseModel):
    username: Annotated[str, Field(min_length=3, max_length=50, pattern=r"^[a-z0-9_]+$")]
    email: str
    age: Annotated[int, Field(ge=18, le=120)]

    @field_validator("email")
    @classmethod
    def email_must_have_at(cls, v: str) -> str:
        if "@" not in v:
            raise ValueError("invalid email address")
        return v.lower()


class UserResponse(BaseModel):
    id: int
    username: str
    email: str
    created_at: datetime

    model_config = {"from_attributes": True}  # enables ORM mode

Pydantic v2 validates on construction. If age is a string that cannot be coerced to an integer, FastAPI returns a 422 with a structured error body – no manual validation code required.

Path Parameters, Query Parameters, and Request Body
#

main.py
from fastapi import FastAPI, Query
from models import CreateUserRequest, UserResponse

app = FastAPI(title="User API", version="1.0.0")


@app.get("/users/{user_id}", response_model=UserResponse)
async def get_user(
    user_id: int,
    include_deleted: bool = Query(default=False, description="include soft-deleted users"),
):
    # user_id is a path parameter (required, typed as int)
    # include_deleted is a query parameter with a default
    user = await db.get_user(user_id, include_deleted=include_deleted)
    if user is None:
        raise HTTPException(status_code=404, detail="user not found")
    return user


@app.post("/users", response_model=UserResponse, status_code=201)
async def create_user(body: CreateUserRequest):
    # body is automatically parsed from the JSON request body
    user = await db.create_user(body.username, body.email, body.age)
    return user

response_model=UserResponse is critical. It tells FastAPI to serialize the return value through the Pydantic model, which strips any fields not declared in UserResponse. Without it, internal database fields (password hashes, internal IDs, flags) can leak to API consumers.

Async Endpoints: When to Use async def vs def
#

async_vs_sync.py
import httpx
import time


# Use async def for I/O-bound work: database queries, HTTP calls, file reads
@app.get("/async-example")
async def fetch_remote():
    async with httpx.AsyncClient() as client:
        resp = await client.get("https://api.example.com/data")
    return resp.json()


# Use def (sync) for CPU-bound work: image processing, heavy computation
# FastAPI runs sync handlers in a threadpool automatically
@app.get("/sync-example")
def compute_heavy():
    # This runs in a threadpool, not blocking the event loop
    result = expensive_cpu_computation()
    return {"result": result}
Warning

Do not call blocking functions (time.sleep, synchronous database drivers, requests.get) inside async def handlers. They block the event loop and serialize all requests. Use await asyncio.sleep, async database drivers (asyncpg, motor), and httpx.AsyncClient.

Dependency Injection with Depends()
#

Dependencies are the cleanest way to share authentication, database sessions, and configuration across endpoints.

dependencies.py
from fastapi import Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker

# --- Database session dependency ---

async_session_factory: async_sessionmaker[AsyncSession] | None = None


async def get_db() -> AsyncSession:
    async with async_session_factory() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise


# --- JWT Bearer authentication dependency ---

async def get_current_user(
    authorization: str = Header(),
    db: AsyncSession = Depends(get_db),
) -> dict:
    if not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="missing bearer token")

    token = authorization.removeprefix("Bearer ")
    payload = verify_jwt(token)  # raises on invalid token
    if payload is None:
        raise HTTPException(status_code=401, detail="invalid token")

    user = await db.get(User, payload["sub"])
    if user is None:
        raise HTTPException(status_code=401, detail="user not found")
    return user
routes.py
from fastapi import Depends
from dependencies import get_db, get_current_user


@app.get("/me", response_model=UserResponse)
async def get_profile(current_user=Depends(get_current_user)):
    return current_user


@app.delete("/users/{user_id}", status_code=204)
async def delete_user(
    user_id: int,
    db: AsyncSession = Depends(get_db),
    _: dict = Depends(get_current_user),  # auth check without using the value
):
    await db.delete(await db.get(User, user_id))

FastAPI resolves the dependency graph automatically. get_current_user itself depends on get_db, so one database session is shared across both.

HTTPException and Custom Exception Handlers
#

exceptions.py
from fastapi import Request
from fastapi.responses import JSONResponse


class RateLimitExceeded(Exception):
    def __init__(self, retry_after: int):
        self.retry_after = retry_after


@app.exception_handler(RateLimitExceeded)
async def rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse:
    return JSONResponse(
        status_code=429,
        content={"detail": "rate limit exceeded", "retry_after": exc.retry_after},
        headers={"Retry-After": str(exc.retry_after)},
    )

Background Tasks
#

Use BackgroundTasks for work that should not block the HTTP response: sending emails, recording analytics, triggering async pipelines.

background.py
from fastapi import BackgroundTasks


async def send_welcome_email(email: str, username: str) -> None:
    # runs after the response is sent
    await email_client.send(
        to=email,
        subject="Welcome",
        body=f"Hi {username}, your account is ready.",
    )


@app.post("/users", response_model=UserResponse, status_code=201)
async def create_user(body: CreateUserRequest, background_tasks: BackgroundTasks):
    user = await db.create_user(body.username, body.email)
    background_tasks.add_task(send_welcome_email, body.email, body.username)
    return user  # response sent immediately; email runs after
Note

BackgroundTasks runs in the same process after the response is sent. For durable background work (retries, persistence across restarts), use a proper task queue like Celery or ARQ.

Production Deployment
#

# Single process, auto-reload on file change
uvicorn main:app --reload --host 0.0.0.0 --port 8000

Never use --reload in production. It watches the filesystem and restarts the process – it is not safe or performant for production traffic.

# Multiple workers for CPU parallelism
# Rule of thumb: (2 * CPU_count) + 1
gunicorn main:app \
  --workers 5 \
  --worker-class uvicorn.workers.UvicornWorker \
  --bind 0.0.0.0:8000 \
  --timeout 30 \
  --graceful-timeout 10 \
  --access-logfile -

Each Gunicorn worker is a separate process running a Uvicorn event loop. This gives you process-level parallelism (Gunicorn) and non-blocking I/O (Uvicorn) in combination.

For containerized deployments, use this as your CMD in a Dockerfile:

Dockerfile
FROM python:3.12-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

ENV WORKERS=4
CMD gunicorn main:app \
    --workers $WORKERS \
    --worker-class uvicorn.workers.UvicornWorker \
    --bind 0.0.0.0:8000

Load environment configuration via Pydantic Settings:

config.py
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    database_url: str
    secret_key: str
    environment: str = "production"
    debug: bool = False

    model_config = {"env_file": ".env"}


settings = Settings()
Common mistakes and how to avoid them

Using def instead of async def for I/O-bound handlers A synchronous database call inside an async def handler blocks the event loop while waiting for the database. Every other request queues behind it. Use async drivers (asyncpg, motor, aioredis) with await.

Not using response_model Without response_model, FastAPI serializes the raw return value. If your handler returns a SQLAlchemy ORM object, internal fields (hashed passwords, internal flags, foreign keys) will appear in the response. Always declare response_model on every endpoint.

No dependency injection for database sessions Creating a new database connection per handler without a dependency leads to connection leaks when exceptions occur before the connection is closed. The get_db dependency with yield ensures the session is always rolled back and closed, even on exceptions.

Running the Uvicorn dev server in production uvicorn main:app starts a single-process server with no worker management. A single uncaught exception takes down the entire service. Gunicorn manages worker restarts, graceful reloads, and process supervision.

Broken handler signatures The @app.get("/") handler does not take request and response as positional arguments. FastAPI uses function signature inspection to inject path parameters, query parameters, headers, and bodies. The correct signature for a simple GET with no parameters is async def handler() -> dict.


If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.

Related