FastAPI Mastery

Build modern Python APIs at lightning speed. Every concept explained with code.

01 What is FastAPI?

What is itFastAPI is a modern, high-performance Python web framework for building HTTP APIs, released by Sebastián Ramírez in December 2018. It sits on top of two libraries: Starlette (an ASGI toolkit that handles routing, middleware, WebSockets, and the event loop) and Pydantic (a data validation library that uses Python type hints). The framework's entire philosophy is: "you've already written type hints for your editor — let us use those for validation, serialization, and OpenAPI docs for free." It requires Python 3.8+ and natively supports both async def and regular def route handlers.
Key features
  • Type-hint driven: Function signatures become the API contract — parameters, bodies, and responses are all declared via annotations like item: Item or q: str | None = None.
  • Automatic OpenAPI/Swagger: Every project gets a live /docs (Swagger UI) and /redoc (ReDoc) with zero configuration.
  • Built-in validation: Invalid payloads return structured 422 Unprocessable Entity errors without writing a single if statement.
  • Async-native: Built on ASGI so it handles WebSockets, Server-Sent Events, and long-lived connections alongside normal HTTP.
  • Dependency Injection: A first-class Depends() system for auth, DB sessions, and shared logic.
  • Starlette features: Middleware, background tasks, test client, static files, template rendering.
How it differs
  • vs Flask: Flask is a WSGI microframework — synchronous by default, no built-in validation, no auto docs. You bolt on flask-restful, marshmallow, flasgger yourself. FastAPI is async-first and gives you the whole stack out of the box.
  • vs Django / DRF: Django is a "batteries-included" full-stack framework (ORM, admin, templates, auth). FastAPI is API-only and unopinionated about the DB layer. DRF serializers are more verbose than Pydantic models (~3× more code).
  • vs Starlette: Starlette is the low-level ASGI framework FastAPI is built on. FastAPI adds Pydantic integration, DI, OpenAPI — think of it as "Starlette + batteries."
  • vs Node.js Express: Express has no built-in validation or docs; you wire up joi, zod, swagger-jsdoc. FastAPI's "type hints = validation = docs" pipeline is significantly less boilerplate.
  • vs Go Gin / Java Spring: Gin is ~5–10× faster raw but lacks Python's ecosystem; Spring Boot has richer enterprise tooling but 100× more config XML/annotations.
Why use itYou get type safety, auto-generated interactive docs, runtime validation, and async concurrency all from a single function signature. This means fewer bugs (Pydantic catches bad input before your code runs), faster iteration (docs update live as you code), and better performance (ASGI + uvicorn rivals Node.js throughput). For microservices, ML model serving, and modern API backends, it's arguably the best Python choice in 2024+.
Common gotchas
  • Blocking I/O in async def: Calling requests.get() or a sync DB driver inside an async route blocks the event loop — use httpx.AsyncClient or run blocking calls in a thread pool via run_in_threadpool.
  • Pydantic v1 vs v2 migration: v2 (2023) changed .dict().model_dump(), Config class → model_config, validators moved to @field_validator. FastAPI 0.100+ supports both.
  • Running without a production server: uvicorn --reload is a dev server; use gunicorn -k uvicorn.workers.UvicornWorker in production.
Real-world examplesNetflix (ML model serving via Metaflow), Uber (Ludwig model UI), Microsoft (Windows ML APIs), Explosion AI (spaCy / Prodigy), Hugging Face (Inference APIs), OpenAI (internal tooling), Cloudflare, and thousands of startups. It's the default framework for anything involving ML model deployment because data scientists already write type-hinted Python.

FastAPI is a modern, high-performance web framework for building APIs with Python 3.7+ based on standard type hints. It's built on top of Starlette (for the web parts) and Pydantic (for data validation).

Why FastAPI?

Fastest Python framework with automatic validation, docs, and async support — all from type hints.

FeatureFlaskFastAPIDjango REST
SpeedSlow (WSGI)Very fast (ASGI)Slow (WSGI)
AsyncLimitedNativeLimited
ValidationManualAutomatic (Pydantic)Serializers
Auto docsNoYes (Swagger + ReDoc)Plugin
Type hintsOptionalCore featureOptional
Learning curveEasyEasySteep

02 Setup & First App

What is itSetting up FastAPI means installing the framework and an ASGI server to run it. FastAPI is a library — it does not bind to a socket itself. You pair it with uvicorn (the de-facto ASGI server, based on uvloop and httptools) or hypercorn. The minimal "hello world" is literally 5 lines of Python plus one uvicorn main:app command.
Installation
  • pip install "fastapi[standard]" — since FastAPI 0.100+ this is the recommended install; it bundles uvicorn, httpx (test client), jinja2, python-multipart (form parsing).
  • pip install fastapi uvicorn — minimal install for production deploys where you know exactly what you need.
  • Use a venv: python -m venv .venv && source .venv/bin/activate. Or modern tools: uv, poetry, pipenv, pdm.
Running the app
  • uvicorn main:app --reload — dev mode with hot reload on file save. main = Python module, app = FastAPI instance.
  • fastapi dev main.py — new in FastAPI 0.100+; wraps uvicorn with sensible dev defaults.
  • fastapi run main.py — production mode (no reload, multiple workers).
  • Default port: 8000. Override with --host 0.0.0.0 --port 80.
How it differs
  • vs Flask: Flask bundles its own dev server (app.run()) and uses WSGI (gunicorn/waitress). FastAPI requires explicit ASGI server selection, which is more flexible but one extra step.
  • vs Django: Django has manage.py runserver and django-admin startproject scaffolding. FastAPI is unopinionated — no project generator, you arrange your files however you want.
  • vs Node.js Express: Express's app.listen(3000) binds the socket directly; FastAPI separates app from server which maps better to containerized/K8s deploys.
Common gotchas
  • Wrong app reference: uvicorn main:app — if your file is app.py with application = FastAPI(), use uvicorn app:application.
  • --reload in production: Kills performance because it watches the filesystem; never use it in prod.
  • Missing python-multipart: File uploads and form data silently 422 without it — the [standard] extras prevent this.
  • Port already in use: lsof -i :8000 to find the old process, or just change ports.
Production setupFor real deployments use gunicorn -k uvicorn.workers.UvicornWorker -w 4 main:app — gunicorn is a battle-tested process manager that spawns N uvicorn workers. Rule of thumb: workers = (2 × CPU cores) + 1. Put nginx or a cloud load balancer in front for TLS termination. Alternative: uvicorn main:app --workers 4 works too but gunicorn has better signal handling and graceful shutdown.
# Install
pip install fastapi uvicorn[standard]

# uvicorn = ASGI server that runs your FastAPI app

Hello World

A working API in just a few lines — create an app, add a route, and run it.

# main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def root():
    return {"message": "Hello, World!"}

@app.get("/health")
def health():
    return {"status": "ok"}
# Run it
uvicorn main:app --reload

# main   = file name (main.py)
# app    = the FastAPI instance variable
# --reload = auto-restart on code changes (dev only)

# Now visit:
# http://127.0.0.1:8000        → your API
# http://127.0.0.1:8000/docs   → Swagger UI (interactive docs)
# http://127.0.0.1:8000/redoc  → ReDoc (alternative docs)
That's it. You have a running API with auto-generated interactive documentation. No config, no boilerplate.

03 Routing & HTTP Methods

What is itRouting is the mechanism that maps incoming HTTP requests (method + URL path) to Python functions. In FastAPI, you attach a decorator like @app.get("/items") to a function and that function becomes the handler for that route. FastAPI supports all standard HTTP verbs via @app.get, @app.post, @app.put, @app.patch, @app.delete, @app.options, @app.head, @app.trace. Under the hood these are just thin wrappers that call app.add_api_route(path, func, methods=[...]).
HTTP methods semantics
  • GETread, idempotent, cacheable, no body. GET /users/123.
  • POSTcreate, not idempotent. POST /users with JSON body creates a new user.
  • PUTreplace, idempotent. PUT /users/123 replaces the entire resource.
  • PATCHpartial update, not always idempotent. Use with model.model_dump(exclude_unset=True).
  • DELETEremove, idempotent. Often returns 204 No Content.
Route features
  • Tags: @app.get("/items", tags=["items"]) groups endpoints in Swagger UI.
  • Summary & description: Shown in docs; description supports Markdown.
  • Status code: status_code=201 sets the default success code.
  • Response model: response_model=UserOut filters the output through a Pydantic schema.
  • Deprecation: deprecated=True shows a strikethrough in the docs.
  • APIRouter: router = APIRouter(prefix="/users", tags=["users"]) lets you split routes across files.
Route order mattersFastAPI matches routes in the order they were registered. So /users/me must be defined before /users/{user_id}, otherwise the latter swallows /users/me as user_id="me" and crashes on type conversion. This is a very common bug. Contrast with Flask (uses Werkzeug's routing which ranks by specificity automatically) or Django (uses explicit regex order).
How it differs
  • vs Flask: Flask uses @app.route("/items", methods=["GET", "POST"]) — one decorator with methods list. FastAPI splits them for clearer OpenAPI schemas.
  • vs Django: Django uses urlpatterns = [path("items/", view)] in a separate urls.py — much more verbose but easier to audit all routes at once.
  • vs Express: app.get('/items', (req, res) => ...) — very similar API surface, but Express has no type-based validation.
  • vs Spring: @GetMapping("/items") on a method — similar decorator approach but with XML/annotation config overhead.
Common gotchasPath collision with type conversion (see "Route order"), forgetting trailing slashes (/items vs /items/ — FastAPI treats them as different unless you redirect), and using @app.get for something that mutates state (breaks caching, proxies, CDN semantics). Also: don't register the same path+method twice — the second silently overrides the first in some FastAPI versions.
from fastapi import FastAPI

app = FastAPI()

# Each decorator = one endpoint
@app.get("/users")          # GET    — read data
def list_users(): ...

@app.post("/users")         # POST   — create data
def create_user(): ...

@app.put("/users/{id}")     # PUT    — full update
def update_user(id: int): ...

@app.patch("/users/{id}")   # PATCH  — partial update
def patch_user(id: int): ...

@app.delete("/users/{id}")  # DELETE — remove data
def delete_user(id: int): ...

# Route order matters! More specific routes FIRST.
@app.get("/users/me")       # ← this MUST come before /users/{id}
def get_current_user(): ...

@app.get("/users/{id}")     # ← otherwise "me" would match as an {id}
def get_user(id: int): ...

APIRouter — Organize Routes

Split your routes into separate files with prefixes and tags — keeps large APIs clean and organized.

# routes/users.py
from fastapi import APIRouter

router = APIRouter(prefix="/users", tags=["Users"])

@router.get("/")
def list_users(): ...

@router.get("/{id}")
def get_user(id: int): ...

# main.py
from fastapi import FastAPI
from routes.users import router as users_router

app = FastAPI()
app.include_router(users_router)  # /users/ and /users/{id}

04 Path Parameters

What is itPath parameters are dynamic segments in a URL path, declared with curly braces: /users/{user_id}. FastAPI extracts the segment, parses it using the Python type annotation on the function argument, validates it, and passes it in. If the annotation is user_id: int and the URL is /users/abc, FastAPI returns a 422 with a clear error — no manual int() call or try/except needed.
Syntax & types
  • async def read_user(user_id: int) — integer path param, auto-validated and converted.
  • async def read_user(user_id: UUID) — works with uuid.UUID, datetime.date, Decimal, etc.
  • Enum path params: declare class ModelName(str, Enum) and use model_name: ModelName — gives you a dropdown in Swagger UI.
  • Path(...) for metadata: user_id: int = Path(..., gt=0, le=10000, description="User ID") adds validation constraints and docs.
  • Path containing slash: use the Starlette convertor /files/{file_path:path} to capture a/b/c.txt.
Validation constraintsVia Path() you can enforce gt (greater than), ge (greater or equal), lt, le, min_length, max_length, regex/pattern. These constraints appear in the OpenAPI schema so Swagger UI shows them and client generators can enforce them. Example: item_id: int = Path(..., gt=0, le=1000).
How it differs
  • vs Flask: Flask uses converters like /users/<int:user_id> — type comes from the URL, not the function sig. Less flexible and not tied to modern type hints.
  • vs Django: path("users/<int:user_id>/", view) — same converter style as Flask.
  • vs Express: /users/:id — everything is a string, you must manually parseInt(req.params.id).
  • vs Spring: @PathVariable Long userId — similar type-driven approach but with Java's verbose annotation style.
Common gotchas
  • Enum with non-string base: Must inherit from (str, Enum) (or (int, Enum)) for JSON serialization to work.
  • UUID gotcha: Passing a string UUID works, but the function receives a UUID object — str(user_id) when querying the DB.
  • Path {file_path:path}: Captures slashes, but a leading slash means /files//etc/passwd could be dangerous — sanitize paths.
  • Empty path param: /users// doesn't match /users/{id} — it's a 404, not a validation error.
Real-world examplesEvery REST API has them: GET /repos/{owner}/{repo}/issues/{number} (GitHub), GET /v1/customers/{id}/invoices (Stripe), GET /users/{username} (Twitter/X). FastAPI makes these concise: one decorator, one type-annotated function, automatic docs.
# Type hint = automatic validation + conversion
@app.get("/users/{user_id}")
def get_user(user_id: int):      # auto-converted to int
    return {"user_id": user_id}

# GET /users/42    → {"user_id": 42}
# GET /users/hello → 422 Validation Error (not an int)

# ── Enum for fixed values ──
from enum import Enum

class Role(str, Enum):
    admin = "admin"
    user = "user"
    guest = "guest"

@app.get("/roles/{role}")
def get_role(role: Role):
    return {"role": role.value}

# GET /roles/admin → {"role": "admin"}
# GET /roles/xyz   → 422 error (not a valid Role)

05 Query Parameters

What is itQuery parameters are the key-value pairs in the URL after ? — for example /items?skip=0&limit=10&q=phone. In FastAPI, any function argument that is not a path parameter and not a Pydantic body is treated as a query parameter by default. Their type annotation drives automatic parsing, validation, and defaulting. q: str = None becomes an optional string query param; limit: int = 10 becomes a required-with-default int.
Syntax & types
  • Required: q: str (no default) — FastAPI returns 422 if missing.
  • Optional: q: str | None = None or Optional[str] = None.
  • With default: skip: int = 0, limit: int = 10.
  • List params: tags: list[str] = Query([]) parses ?tags=a&tags=b&tags=c into ["a","b","c"].
  • Booleans: active: bool = False — accepts true/false/1/0/yes/no/on/off.
Query() metadataFor richer validation use Query(): q: str = Query(None, min_length=3, max_length=50, pattern="^[a-z]+$", title="Search", description="Search term", alias="search-term", deprecated=True). The alias is vital when the URL param name isn't a valid Python identifier (e.g., hyphens). Query() supports the same constraints as Path(): gt, ge, lt, le, min_length, max_length, pattern.
How it differs
  • vs Flask: Flask uses request.args.get('q', type=int) — manual extraction, no built-in validation, no auto docs.
  • vs Django/DRF: DRF serializers can validate query params via serializer.is_valid(), but it's far more code.
  • vs Express: req.query.q returns a string; you validate with joi/zod manually.
  • vs Spring: @RequestParam(required=false) String q — similar in spirit but heavier annotation syntax.
Common gotchas
  • List defaults: tags: list[str] = [] fails — Python mutable default gotcha. Use tags: list[str] = Query(default_factory=list) or Query([]).
  • Boolean coercion surprise: ?active=yes becomes True, but ?active=random fails with 422 — not silently True.
  • Required with Query(): q: str = Query(...) means required; Query(None) means optional. Pydantic v2 prefers Query() no-args for required.
  • Reserved Python keywords: Can't name param class or from — use alias="class".
Real-world patternsPagination (?page=2&size=50), filtering (?status=active&role=admin), sorting (?sort=-created_at), search (?q=laptop), field selection (?fields=id,name,email). All major APIs — Stripe, GitHub, Twilio, Shopify — follow these patterns and FastAPI makes them declarative via type hints.
# Any parameter NOT in the path = query parameter
@app.get("/users")
def list_users(
    skip: int = 0,           # optional, default 0
    limit: int = 10,          # optional, default 10
    active: bool = True       # optional, default True
):
    return {"skip": skip, "limit": limit, "active": active}

# GET /users?skip=20&limit=5&active=false

# ── Required query params (no default) ──
@app.get("/search")
def search(q: str):           # required! no default.
    return {"query": q}

# GET /search         → 422 error (q is required)
# GET /search?q=hello → {"query": "hello"}

# ── Optional with None ──
@app.get("/items")
def list_items(q: str | None = None):
    if q:
        return {"results": f"searching for {q}"}
    return {"results": "all items"}

# ── Validation with Query() ──
from fastapi import Query

@app.get("/search")
def search(
    q: str = Query(min_length=3, max_length=50, pattern="^[a-z]+"),
    page: int = Query(default=1, ge=1),           # >= 1
    size: int = Query(default=10, le=100),         # <= 100
):
    return {"q": q, "page": page, "size": size}

06 Request Body

What is itThe request body is the JSON (or form/multipart) payload that clients send with POST, PUT, and PATCH requests. In FastAPI, bodies are declared as Pydantic model type hints: async def create_item(item: Item). FastAPI automatically reads the body, parses the JSON, validates it against the Pydantic schema, and hands you a fully typed Python object. No request.json(), no manual dict access, no key-error exceptions.
How FastAPI decides body vs query
  • If the parameter is a path parameter — it's taken from the URL.
  • If it's a singular Python type (int, str, float, bool) — it becomes a query parameter.
  • If it's a Pydantic BaseModel — it becomes the request body.
  • Multiple Pydantic params = multiple keys inside the JSON body. Use Body(embed=True) to force a single model to be nested under its key name.
Body variations
  • Multiple body params: def update(item: Item, user: User) expects JSON {"item": {...}, "user": {...}}.
  • Scalar in body: importance: int = Body(...) forces a scalar into the body instead of query.
  • Form data: username: str = Form(...) for application/x-www-form-urlencoded (login forms). Requires python-multipart.
  • Raw body: await request.body() inside the function if you need bytes.
How it differs
  • vs Flask: request.get_json() returns a dict; validation is manual with marshmallow or pydantic directly. Many bugs come from data["field"] KeyErrors.
  • vs Django/DRF: DRF uses serializers: ItemSerializer(data=request.data).is_valid(raise_exception=True) — works but ~3× more code and awkward when a field has multiple representations (input vs output).
  • vs Express: app.use(express.json()) middleware + manual validation with zod/joi.
  • vs Spring: @RequestBody Item item with Jackson — similar concept but requires Java classes with boilerplate getters/setters.
Common gotchas
  • Content-Type must be application/json — otherwise FastAPI treats it as form data and returns 422.
  • GET with body: Technically allowed in HTTP but discouraged; FastAPI supports it via Body(...) but clients/proxies may strip it.
  • Extra fields: Pydantic v2 ignores extra fields by default. To reject them set model_config = ConfigDict(extra="forbid").
  • Large bodies: Default uvicorn allows large bodies; set --limit-max-requests or use a reverse proxy for DoS protection.
Real-world examplesCreating resources (POST /users with {"name", "email"}), updating resources (PUT /posts/123), webhooks (Stripe, GitHub, Slack send JSON bodies you must validate fast), and RPC-style APIs. The Pydantic-driven approach eliminates entire classes of bugs (missing fields, wrong types, nested object errors) that plague other frameworks.
from pydantic import BaseModel

class UserCreate(BaseModel):
    name: str
    email: str
    age: int | None = None

@app.post("/users")
def create_user(user: UserCreate):
    # user is already validated and typed!
    return {"name": user.name, "email": user.email}

# POST /users
# Body: {"name": "Yatin", "email": "y@dev.com"}
# → {"name": "Yatin", "email": "y@dev.com"}

# POST /users
# Body: {"name": 123}  → 422 error (name must be str, email missing)

# ── Body + Path + Query together ──
@app.put("/users/{user_id}")
def update_user(
    user_id: int,              # path param
    user: UserCreate,            # request body
    notify: bool = False       # query param
):
    return {"id": user_id, "user": user.model_dump(), "notify": notify}

07 Pydantic Models Deep Dive

What is itPydantic is a data validation and settings management library that uses Python type annotations to define schemas. A Pydantic model is a class that inherits from BaseModel — at instantiation time, Pydantic parses the input, validates types, applies custom validators, and either returns a fully typed object or raises ValidationError. It's what makes FastAPI's "types = contract" design possible. Pydantic v2 (released 2023) rewrote the core in Rust and is 5–50× faster than v1.
Core features
  • Type coercion: "123" becomes 123 for an int field (or use Strict[int] to disable).
  • Nested models: class Order(BaseModel): items: list[Item] — recursive validation.
  • Field metadata: Field(..., gt=0, le=100, description="...", examples=[42]).
  • Validators: @field_validator("email") (v2) or @validator("email") (v1) for custom logic.
  • Model validators: @model_validator(mode="after") to validate across fields.
  • Computed fields: @computed_field decorates a property that's serialized in output.
  • Serialization control: .model_dump(), .model_dump_json(), exclude, include, exclude_unset, exclude_none, by_alias.
Pydantic v1 vs v2
  • .dict().model_dump(), .json().model_dump_json().
  • class Configmodel_config = ConfigDict(...).
  • @validator@field_validator; @root_validator@model_validator.
  • parse_objmodel_validate; parse_rawmodel_validate_json.
  • v2 has Annotated[str, StringConstraints(min_length=3)] for PEP 593-style constraints.
How it differs
  • vs Marshmallow: Marshmallow requires defining a separate Schema class — Pydantic combines model and schema into one.
  • vs DRF serializers: DRF needs fields = ['name', 'email'] listing and explicit create()/update() methods. Pydantic uses the class body directly.
  • vs Python dataclasses: @dataclass gives you the class structure but no validation — you must add it manually. Pydantic validates on every instantiation.
  • vs Zod (TypeScript): Very similar feel — z.object({...}) vs class X(BaseModel). Pydantic is probably Zod's closest cousin in Python.
Common gotchas
  • Optional ≠ Nullable: In v2, Optional[str] means "can be None", not "can be absent." Use a default value for absent: name: str | None = None.
  • Mutable defaults: items: list[str] = [] is OK in Pydantic (unlike dataclasses) because it's deep-copied — but still better style to use Field(default_factory=list).
  • Forward refs: Self-referential models need Model.model_rebuild() after definition.
  • ORM integration: To build a model from an ORM object, set model_config = ConfigDict(from_attributes=True) and call Model.model_validate(orm_obj).
Real-world examplesPydantic is used by OpenAI's Python SDK, LangChain, Anthropic SDK, AWS Lambda Powertools, Prefect, Instructor, and virtually every modern Python library dealing with structured data. Pydantic Settings (pydantic-settings) is the standard way to load config from env vars.

Pydantic is the backbone of FastAPI. It handles validation, serialization, and documentation all from your type hints.

from pydantic import BaseModel, Field, field_validator, model_validator
from datetime import datetime

class UserCreate(BaseModel):
    name: str = Field(min_length=2, max_length=50)
    email: str = Field(pattern=r"^[\w.-]+@[\w.-]+\.\w+$")
    age: int = Field(ge=0, le=150)
    tags: list[str] = []

    # Custom validator
    @field_validator("name")
    @classmethod
    def name_must_be_alpha(cls, v):
        if not v.replace(" ", "").isalpha():
            raise ValueError("Name must contain only letters")
        return v.title()

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

    model_config = {"from_attributes": True}  # read from ORM objects

# ── Nested models ──
class Address(BaseModel):
    street: str
    city: str
    country: str = "US"

class UserFull(BaseModel):
    name: str
    address: Address              # nested!
    friends: list["UserFull"] = []  # self-referencing

# ── Useful methods ──
user = UserCreate(name="yatin", email="y@dev.com", age=25)
user.model_dump()            # → dict
user.model_dump_json()       # → JSON string
user.model_dump(exclude={"tags"})  # exclude fields
UserCreate.model_json_schema()     # → JSON Schema (for docs)

08 Response Models

What is itA response model is a Pydantic model declared via response_model=... on a route decorator. FastAPI filters, validates, and serializes the function's return value through this model before sending the HTTP response. This lets you safely return a SQLAlchemy ORM object containing password_hash but only expose id, email, and name in the API. It is FastAPI's primary mechanism for preventing accidental data leaks.
Why separate input and output schemasBest practice is two or three Pydantic models per resource:
  • UserCreate — what the client sends (with password).
  • UserUpdate — all fields optional, for PATCH.
  • UserOut / UserPublic — what the server returns (no password, may add created_at).
This pattern is sometimes called "schema-per-concern" and maps well to domain-driven design.
Key features
  • response_model=UserOut — filter output through this schema.
  • response_model_exclude_unset=True — omit fields the model didn't explicitly set (useful for PATCH).
  • response_model_exclude_none=True — omit null values.
  • response_model_exclude={"password"} — dynamic exclusion.
  • response_model=list[UserOut] — for list endpoints.
  • Union[UserOut, AdminOut] + response_model_by_alias=True — polymorphic responses.
How it differs
  • vs Flask/marshmallow: You must manually schema.dump(obj) before returning — easy to forget. FastAPI enforces it.
  • vs DRF: DRF also has separate read/write serializers but it's manual plumbing. FastAPI's response_model is one kwarg.
  • vs Express: No built-in concept; you write res.json({id, email, name}) and pray you don't forget a sensitive field.
Common gotchas
  • Return type annotation conflicts: If you declare both -> User return type and response_model=UserOut, FastAPI uses response_model and logs a warning.
  • ORM objects: Need model_config = ConfigDict(from_attributes=True) on the response model.
  • Performance: Every response goes through Pydantic validation — for very hot paths you can return a JSONResponse directly to skip it.
  • Error responses: response_model only applies to success responses; errors go through HTTPException.
Real-world examplesStripe-style APIs always return a public view of a resource: a Customer object from the DB has internal fields like deleted, internal_notes, fraud_score — the public response only has id, email, created, metadata. response_model enforces this boundary at the framework level, which is essential for compliance (GDPR, PCI) and security.
# response_model controls what the client SEES
# Your function can return more data — FastAPI filters it

class UserDB(BaseModel):
    id: int
    name: str
    email: str
    hashed_password: str   # sensitive!

class UserOut(BaseModel):
    id: int
    name: str
    email: str
    # no password field → it's filtered out

@app.get("/users/{id}", response_model=UserOut)
def get_user(id: int):
    # even if this returns a UserDB with password,
    # the response only includes id, name, email
    return UserDB(id=id, name="Yatin", email="y@dev.com", hashed_password="xxx")

# ── List responses ──
@app.get("/users", response_model=list[UserOut])
def list_users():
    return [...]

09 Status Codes & Error Handling

What is itHTTP status codes are 3-digit numbers in the response that tell the client what happened. FastAPI gives you fine-grained control: status_code=201 on the decorator, HTTPException(status_code=404, detail="Not found") for errors, and @app.exception_handler() for global error mapping. The standard library module fastapi.status exposes named constants like status.HTTP_404_NOT_FOUND for readability.
Status code families
  • 2xx Success: 200 OK (generic), 201 Created (POST creating a resource), 202 Accepted (async/queued), 204 No Content (DELETE success).
  • 3xx Redirection: 301 Moved Permanently, 302 Found, 304 Not Modified (caching).
  • 4xx Client errors: 400 Bad Request, 401 Unauthorized (not logged in), 403 Forbidden (logged in but no permission), 404 Not Found, 409 Conflict (duplicate), 422 Unprocessable Entity (FastAPI's default for validation failures), 429 Too Many Requests.
  • 5xx Server errors: 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout.
Error handling mechanisms
  • HTTPException: raise HTTPException(status_code=404, detail="Item not found", headers={"X-Error": "..."}).
  • Custom exception + handler: Define class UnicornException(Exception), then @app.exception_handler(UnicornException) returns a custom JSONResponse.
  • Override validation errors: @app.exception_handler(RequestValidationError) lets you reformat 422 responses.
  • Global 500 handler: @app.exception_handler(Exception) catches unhandled exceptions.
How it differs
  • vs Flask: Flask uses abort(404) or @app.errorhandler(404) — similar pattern but Flask returns HTML by default, FastAPI returns JSON.
  • vs DRF: DRF has APIException subclasses like NotFound, PermissionDenied. FastAPI's single HTTPException is simpler but slightly less type-safe.
  • vs Express: Express uses res.status(404).json({...}) — no exception-based flow, you must return early.
  • vs Spring: @ResponseStatus(HttpStatus.NOT_FOUND) on exception classes — similar declarative style.
Common gotchas
  • 422 vs 400: FastAPI uses 422 for validation errors (Pydantic). Some teams prefer 400 for compatibility — override via custom exception handler.
  • Return vs raise: return HTTPException(...) sends the exception object as a JSON body with status 200 — always raise.
  • HTTPException detail: Leaks info in production — never include stack traces or DB errors.
  • Swallowing HTTPException: A try/except Exception inside a handler catches HTTPException too — use except HTTPException: raise first.
Best practicesReturn RFC 7807 Problem Details JSON (type, title, status, detail, instance) for consistency. Map internal domain exceptions (e.g., UserNotFoundError) to HTTP via a global exception handler so your business logic stays framework-agnostic.
from fastapi import FastAPI, HTTPException, status

# ── Set status code ──
@app.post("/users", status_code=status.HTTP_201_CREATED)
def create_user(user: UserCreate):
    return user

# ── Raise errors ──
@app.get("/users/{id}")
def get_user(id: int):
    user = fake_db.get(id)
    if not user:
        raise HTTPException(
            status_code=404,
            detail="User not found",
            headers={"X-Error": "not-found"}
        )
    return user

# ── Custom exception handler ──
from fastapi.responses import JSONResponse

class ItemNotFound(Exception):
    def __init__(self, item_id: int):
        self.item_id = item_id

@app.exception_handler(ItemNotFound)
async def item_not_found_handler(request, exc):
    return JSONResponse(
        status_code=404,
        content={"error": f"Item {exc.item_id} not found"}
    )
CodeMeaningWhen to use
200OKSuccessful GET/PUT/PATCH
201CreatedSuccessful POST
204No ContentSuccessful DELETE
400Bad RequestInvalid input
401UnauthorizedNot authenticated
403ForbiddenAuthenticated but no permission
404Not FoundResource doesn't exist
422UnprocessableValidation error (auto by FastAPI)
500Server ErrorUnhandled exception

10 Headers & Cookies

What is itHeaders are key-value metadata fields in both requests and responses — things like Content-Type, Authorization, User-Agent, X-Request-ID. Cookies are a special header (Cookie: name=value) used for sessions and tracking. FastAPI exposes both via declarative parameter types: user_agent: str = Header(None) and session_id: str = Cookie(None). Header names with hyphens are automatically converted to snake_case (X-Request-IDx_request_id).
Reading headers & cookies
  • user_agent: str | None = Header(None) — matches User-Agent header.
  • auth: str = Header(..., alias="Authorization") — explicit alias when you want exact name.
  • x_token: list[str] = Header([]) — some headers appear multiple times.
  • session: str | None = Cookie(None) — read a cookie by name.
  • convert_underscores=False on Header() disables hyphen-to-underscore conversion.
Setting response headers & cookies
  • Inject Response: def handler(response: Response) — set response.headers["X-Foo"] = "bar" and response.set_cookie("session", "abc123").
  • Return JSONResponse: return JSONResponse(content={...}, headers={"X-Foo": "bar"}).
  • Cookie flags: set_cookie(httponly=True, secure=True, samesite="lax", max_age=3600, domain=".example.com").
How it differs
  • vs Flask: request.headers.get("User-Agent") and request.cookies.get("session") — works but no type validation and not in the OpenAPI spec.
  • vs DRF: request.META.get("HTTP_USER_AGENT") — Django's HTTP_ prefix convention is clunkier.
  • vs Express: req.headers['user-agent'] / req.cookies.session (with cookie-parser middleware).
Security considerations
  • HttpOnly: Cookies with httponly=True can't be read by JavaScript — mitigates XSS session theft.
  • Secure: secure=True only sends cookies over HTTPS.
  • SameSite: "strict" / "lax" / "none" — protects against CSRF.
  • Don't trust X-Forwarded-For unless you verify it came from your load balancer — clients can spoof it.
  • Size limits: Cookies are max 4KB each, 50 per domain — don't stuff JWTs bigger than ~3KB.
Common gotchas
  • Header case: HTTP headers are case-insensitive by spec but dict lookups are case-sensitive — use request.headers.get() which handles it.
  • CORS and cookies: Need allow_credentials=True in CORSMiddleware AND the frontend must send credentials: 'include'.
  • Cookie domain: A cookie set on api.example.com is NOT sent to www.example.com unless domain is .example.com.
from fastapi import Header, Cookie, Response

# ── Read headers ──
@app.get("/agent")
def get_agent(user_agent: str = Header()):
    return {"agent": user_agent}
# Header names auto-convert: user_agent → User-Agent

# ── Read cookies ──
@app.get("/me")
def get_me(session_id: str | None = Cookie(default=None)):
    return {"session": session_id}

# ── Set headers & cookies in response ──
@app.post("/login")
def login(response: Response):
    response.set_cookie(key="session_id", value="abc123", httponly=True)
    response.headers["X-Custom"] = "my-value"
    return {"message": "logged in"}

11 Dependency Injection

What is itDependency Injection (DI) is FastAPI's mechanism for declaring reusable pieces of logic that a route needs — a database session, the current user, pagination params, a Redis client — and having FastAPI compute and pass them in automatically. You declare a dependency with def get_db(): yield SessionLocal() and consume it with db: Session = Depends(get_db). FastAPI handles resolution, caching per-request, and cleanup (via yield). It's arguably FastAPI's killer feature — no other Python framework has anything this clean.
How it worksWhen a request comes in, FastAPI:
  • Inspects the route function's signature.
  • For each Depends(fn), recursively resolves fn's own dependencies.
  • Calls the dependency (async or sync), caches the result for the rest of that request.
  • After the route returns, runs the finally blocks of yield-based dependencies in reverse order.
Dependencies can depend on other dependencies — you get a full DAG resolved automatically.
Patterns
  • Function dependency: def pagination(skip: int = 0, limit: int = 10): return {...}.
  • Class dependency: Any class with an __init__ can be a dependency — shorthand: pager: Pager = Depends().
  • Generator with cleanup: yield inside the dependency runs teardown after response.
  • Sub-dependencies: def get_current_user(token: str = Depends(oauth2_scheme)) — chained.
  • Path-level dependencies: @app.get("/", dependencies=[Depends(verify_token)]) — runs but doesn't pass value.
  • Global dependencies: app = FastAPI(dependencies=[Depends(log_request)]) — runs for every route.
How it differs
  • vs Flask: Flask has no built-in DI. You use flask-injector, decorators, or g (request-scoped globals). Nothing as elegant as FastAPI's DI.
  • vs Django: Middleware + class-based view mixins approximate DI. Much more verbose.
  • vs Spring: Spring has full-blown DI with @Autowired, component scanning, profiles — more powerful but also a heavy XML/annotation config load. FastAPI's DI is request-scoped only, simpler.
  • vs NestJS (Node.js): NestJS copies Angular's DI container (module-scoped providers). FastAPI is more functional and lightweight.
Common gotchas
  • Caching within request: The same dependency called twice in one request returns the cached value — opt out with Depends(fn, use_cache=False).
  • Yield + raise: If you raise HTTPException after yield, FastAPI logs an error — raise before yielding.
  • Async vs sync mixing: A sync dependency blocks the event loop if the route is async — prefer async dependencies for async routes.
  • Overrides for testing: app.dependency_overrides[get_db] = get_test_db — essential but often forgotten.
Real-world examplesEvery non-trivial FastAPI app uses DI for: database sessions (Depends(get_db)), current user (Depends(get_current_user)), role/permission checks (Depends(require_admin)), tenant resolution in multi-tenant SaaS, rate limiting, and feature flags.

FastAPI's Depends() system lets you share logic across endpoints — database sessions, auth checks, pagination, etc.

from fastapi import Depends

# ── Simple dependency ──
def get_db():
    db = SessionLocal()
    try:
        yield db           # dependency with cleanup!
    finally:
        db.close()

@app.get("/users")
def list_users(db: Session = Depends(get_db)):
    return db.query(User).all()

# ── Dependency with parameters ──
def pagination(skip: int = 0, limit: int = 10):
    return {"skip": skip, "limit": limit}

@app.get("/users")
def list_users(page: dict = Depends(pagination)):
    return {"pagination": page}

# ── Auth dependency ──
def get_current_user(token: str = Header()):
    user = decode_token(token)
    if not user:
        raise HTTPException(status_code=401, detail="Invalid token")
    return user

@app.get("/profile")
def profile(user = Depends(get_current_user)):
    return {"user": user}

# ── Class-based dependency ──
class CommonParams:
    def __init__(self, q: str | None = None, skip: int = 0, limit: int = 10):
        self.q = q
        self.skip = skip
        self.limit = limit

@app.get("/items")
def list_items(params: CommonParams = Depends()):
    return params

12 Middleware & CORS

What is itMiddleware is code that runs before and after every request, sitting between the ASGI server and your route handlers. It's the standard place for cross-cutting concerns: logging, timing, authentication, adding response headers, compression, request ID propagation, and CORS. FastAPI inherits Starlette's middleware system — you can use any ASGI middleware or write a custom one with @app.middleware("http").
Middleware types
  • Function middleware: @app.middleware("http") async def add_timing(request, call_next): ... response = await call_next(request); ...; return response.
  • Class middleware (ASGI): Inherit from BaseHTTPMiddleware or write raw ASGI — more powerful, works with streaming.
  • Built-ins: CORSMiddleware, GZipMiddleware, HTTPSRedirectMiddleware, TrustedHostMiddleware, SessionMiddleware.
CORS explainedCross-Origin Resource Sharing (CORS) is the browser security mechanism that blocks JavaScript at app.example.com from calling api.example.com unless the API explicitly allows it via Access-Control-Allow-Origin headers. FastAPI's CORSMiddleware handles both simple requests and preflight OPTIONS requests:
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://app.example.com"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)
How it differs
  • vs Flask: Flask uses @app.before_request / @app.after_request — similar concept. CORS requires flask-cors extension.
  • vs Django: Middleware is a class with __call__. CORS needs django-cors-headers.
  • vs Express: app.use((req, res, next) => ...) — Express's middleware is the simplest mental model. CORS via cors npm package.
  • vs Spring: @CrossOrigin on controllers + WebMvcConfigurer for global — very enterprise-y.
Common gotchas
  • Order matters: Middleware runs in reverse-registration order on the way out. Add CORSMiddleware LAST so it wraps everything.
  • allow_credentials=True + allow_origins=["*"]: FORBIDDEN by browsers. Must list explicit origins.
  • Body reading in middleware: If you read await request.body(), downstream handlers see an empty body unless you re-inject it via request._body.
  • Performance: BaseHTTPMiddleware has overhead (uses anyio); for hot paths write pure ASGI middleware.
  • Forgetting preflight: Non-simple requests (JSON POST, custom headers) trigger OPTIONS preflight — CORSMiddleware handles this automatically.
Real-world examplesRequest ID / trace ID injection for distributed tracing (X-Request-ID propagated to logs), Sentry error tracking via SentryAsgiMiddleware, Prometheus metrics via starlette_exporter, gzip compression, OpenTelemetry auto-instrumentation. Most production apps stack 5-10 middlewares.
from fastapi.middleware.cors import CORSMiddleware
import time

# ── CORS (allow frontend to call your API) ──
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],  # or ["*"] for all
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# ── Custom middleware ──
@app.middleware("http")
async def add_timing(request, call_next):
    start = time.perf_counter()
    response = await call_next(request)
    elapsed = time.perf_counter() - start
    response.headers["X-Process-Time"] = str(elapsed)
    return response

13 Authentication (JWT)

What is itAuthentication is proving who the user is; authorization is deciding what they can do. FastAPI ships with OAuth2 utilities in fastapi.securityOAuth2PasswordBearer, OAuth2PasswordRequestForm, HTTPBearer, HTTPBasic, APIKeyHeader, APIKeyQuery, APIKeyCookie. These integrate with DI so a route parameter token: str = Depends(oauth2_scheme) gives you the bearer token and adds the "Authorize" button to Swagger UI.
What is a JWTA JSON Web Token is a URL-safe, signed (not necessarily encrypted) string with three parts separated by dots: header.payload.signature. The header declares the signing algorithm (HS256, RS256), the payload contains claims (sub, exp, iat, iss, plus your custom data), and the signature is computed over header.payload with a secret or private key. Because it's signed, the server can verify authenticity without a DB lookup — making it ideal for stateless auth.
Typical flow
  • Login: Client POSTs {username, password} to /token. Server verifies with bcrypt.checkpw, creates a JWT with jwt.encode({"sub": user.id, "exp": now + 30min}, SECRET, "HS256"), returns it.
  • Authenticated request: Client sends Authorization: Bearer <token>. FastAPI extracts via OAuth2PasswordBearer.
  • Verify: Server calls jwt.decode(token, SECRET, algorithms=["HS256"]), fetches user, returns to route.
  • Refresh: Pair short-lived access token (15–30min) with long-lived refresh token (7–30 days).
Libraries
  • python-jose[cryptography] — traditional JWT library (v1).
  • pyjwt — alternative, heavily used.
  • passlib[bcrypt] — password hashing (bcrypt, argon2).
  • authlib — full OAuth2/OIDC (for SSO with Google, Microsoft).
  • fastapi-users / fastapi-login — opinionated auth packages.
How it differs
  • vs Flask: flask-login for sessions, flask-jwt-extended for JWT — extra packages, no OpenAPI integration.
  • vs Django: django.contrib.auth is session-based and tied to the ORM. DRF has TokenAuthentication and SimpleJWT.
  • vs NextAuth/Clerk: Hosted auth services handle social login, passwordless, MFA out of the box. FastAPI's built-ins are low-level primitives.
Security gotchas
  • Never put secrets in JWT: Payload is base64, not encrypted — anyone can read it.
  • alg: none attack: Always pass explicit algorithms=["HS256"] to decode; never trust the header.
  • Revocation: JWTs are stateless — you can't revoke before expiry without a denylist (Redis).
  • Store in HttpOnly cookies, not localStorage (prevents XSS token theft).
  • Short expiry: Access tokens <30min; long-lived = more blast radius.
  • Use argon2id or bcrypt with cost ≥12 for password hashing; never MD5/SHA1.
Real-world examplesOAuth2 Bearer tokens are the standard for API auth: Stripe, GitHub, Twilio, Twitter all use Authorization: Bearer. For user-facing apps, many teams now skip JWT and use session cookies with Redis for simplicity and revocability.
# pip install python-jose[cryptography] passlib[bcrypt]
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import jwt, JWTError
from passlib.context import CryptContext
from datetime import datetime, timedelta

SECRET_KEY = "your-secret-key"
ALGORITHM = "HS256"
pwd_context = CryptContext(schemes=["bcrypt"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/login")

# ── Hash & verify passwords ──
def hash_password(password: str) -> str:
    return pwd_context.hash(password)

def verify_password(plain: str, hashed: str) -> bool:
    return pwd_context.verify(plain, hashed)

# ── Create JWT token ──
def create_token(data: dict, expires_minutes: int = 30):
    payload = data.copy()
    payload["exp"] = datetime.utcnow() + timedelta(minutes=expires_minutes)
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)

# ── Decode & verify token ──
def get_current_user(token: str = Depends(oauth2_scheme)):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        user_id = payload.get("sub")
        if not user_id:
            raise HTTPException(status_code=401, detail="Invalid token")
    except JWTError:
        raise HTTPException(status_code=401, detail="Invalid token")
    return get_user_from_db(user_id)

# ── Login endpoint ──
@app.post("/login")
def login(form: OAuth2PasswordRequestForm = Depends()):
    user = authenticate_user(form.username, form.password)
    if not user:
        raise HTTPException(status_code=401, detail="Bad credentials")
    token = create_token({"sub": str(user.id)})
    return {"access_token": token, "token_type": "bearer"}

# ── Protected endpoint ──
@app.get("/me")
def me(user = Depends(get_current_user)):
    return user

14 Database (SQLAlchemy)

What is itSQLAlchemy is Python's most popular SQL toolkit and ORM, created by Mike Bayer. FastAPI has no built-in ORM — it's deliberately unopinionated — but SQLAlchemy is the standard pairing. It has two layers: Core (SQL expression language, closer to raw SQL) and ORM (classes mapped to tables). SQLAlchemy 2.0 (released 2023) added full async support and a unified 2.0-style API that harmonizes Core and ORM.
Key pieces
  • Engine: create_engine("postgresql+psycopg://...") manages the connection pool.
  • Session: A unit of work — tracks changes and flushes them on commit().
  • Declarative base: class Base(DeclarativeBase): pass — 2.0 style.
  • Model: class User(Base): __tablename__ = "users"; id: Mapped[int] = mapped_column(primary_key=True).
  • Relationships: items: Mapped[list["Item"]] = relationship(back_populates="owner").
  • Async: create_async_engine, AsyncSession, await session.execute(select(User)).
FastAPI + SQLAlchemy patternTypical setup:
  • One database.py with engine and SessionLocal.
  • get_db() dependency that yields a session and closes it in finally.
  • ORM models in models.py, Pydantic schemas in schemas.py.
  • CRUD functions in crud.py that take db: Session and Pydantic input, return ORM objects.
  • Routes call CRUD functions, return ORM objects, and response_model converts to Pydantic with from_attributes=True.
How it differs
  • vs Django ORM: Django's ORM is simpler for CRUD but less powerful for complex queries; tightly coupled to Django. SQLAlchemy is standalone and more flexible.
  • vs Tortoise / SQLModel / Peewee: SQLModel (by the FastAPI author) merges Pydantic + SQLAlchemy into one class. Tortoise is Django-style but async-native. Peewee is lightweight.
  • vs Prisma (Node.js): Prisma has a schema DSL and a generated client; SQLAlchemy uses Python classes.
  • vs Hibernate (Java): Much less XML/annotation overhead; more Pythonic.
MigrationsSQLAlchemy has no built-in migrations — use Alembic (by the same author). Standard flow: alembic init migrations, alembic revision --autogenerate -m "add users", alembic upgrade head. Autogenerate diffs your models against the current DB schema but misses things like column renames — always review generated migrations.
Common gotchas
  • N+1 queries: Accessing user.items lazily fires a query per user. Use selectinload(User.items) or joinedload.
  • Session per request: Never share a session across requests; it's not thread-safe.
  • Sync ORM in async route: Blocks the event loop — use AsyncSession or run_in_threadpool.
  • Commit vs flush: flush() sends SQL but doesn't commit the transaction; commit() ends the transaction.
  • Connection pool exhaustion: Set pool_size, max_overflow, pool_pre_ping=True for production.
Real-world examplesSQLAlchemy powers Reddit, Yelp, Dropbox (historically), countless Flask/FastAPI apps, and ML pipelines. For FastAPI specifically, SQLModel is increasingly popular because it eliminates the Pydantic/SQLAlchemy duplication.
# pip install sqlalchemy

# ── database.py ──
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase

DATABASE_URL = "sqlite:///./app.db"
# For PostgreSQL: "postgresql://user:pass@localhost/dbname"

engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine, autoflush=False)

class Base(DeclarativeBase):
    pass

# ── models.py ──
from sqlalchemy import Column, Integer, String, Boolean
from database import Base

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True, index=True)
    name = Column(String, nullable=False)
    email = Column(String, unique=True, index=True)
    is_active = Column(Boolean, default=True)

# ── Dependency ──
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

# ── CRUD endpoints ──
@app.post("/users", status_code=201)
def create_user(user: UserCreate, db: Session = Depends(get_db)):
    db_user = User(name=user.name, email=user.email)
    db.add(db_user)
    db.commit()
    db.refresh(db_user)
    return db_user

@app.get("/users")
def list_users(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)):
    return db.query(User).offset(skip).limit(limit).all()

@app.get("/users/{id}")
def get_user(id: int, db: Session = Depends(get_db)):
    user = db.query(User).filter(User.id == id).first()
    if not user:
        raise HTTPException(status_code=404)
    return user

@app.delete("/users/{id}", status_code=204)
def delete_user(id: int, db: Session = Depends(get_db)):
    db.query(User).filter(User.id == id).delete()
    db.commit()

15 Async & Background Tasks

What is itAsync in FastAPI means writing route handlers as async def functions that can await I/O without blocking the event loop. This allows one Python process to handle thousands of concurrent connections — think WebSockets, streaming responses, long polling. Background tasks are small pieces of work that run after the response is sent (BackgroundTasks) so clients don't wait for them. Both rely on Python's asyncio event loop.
Async vs sync routes
  • async def route → runs in the event loop directly. Must use async I/O (httpx, asyncpg, aiomysql).
  • def route → runs in a thread pool automatically (default 40 threads in anyio). Safe for blocking code like requests or sync ORM.
  • Don't mix: Calling requests.get() inside async def blocks the WHOLE event loop — every other request freezes.
  • Escape hatch: from starlette.concurrency import run_in_threadpool to call blocking code from async.
BackgroundTasksInject background_tasks: BackgroundTasks into your route, then background_tasks.add_task(send_email, to=user.email, ...). The task runs AFTER the HTTP response is delivered but in the same process. Great for: sending emails, logging events, warming caches, firing webhooks. It is NOT a replacement for a real task queue.
When to use Celery / RQ / Arq instead
  • BackgroundTasks — trivial, in-process, lost on crash, no retries, no scheduling.
  • Celery — full task queue with Redis/RabbitMQ broker, retries, beat scheduler, distributed. Heavy but battle-tested.
  • RQ — simpler Redis-only queue.
  • Arq — async-native, built for asyncio apps like FastAPI.
  • Dramatiq, Huey, Taskiq — other alternatives.
How it differs
  • vs Flask: Flask is WSGI (synchronous). Flask 2.0+ supports async def but it still runs inside a thread — no true concurrency benefit.
  • vs Django: Django has async def views and an async ORM in 4.1+ but much of the ecosystem is still sync-blocking.
  • vs Node.js: Node is async by design (single event loop). FastAPI reaches similar throughput with uvicorn.
  • vs Go: Go's goroutines are cheaper than Python coroutines and use multiple cores natively — Go still wins raw throughput but FastAPI is closer than Flask by 10×.
Common gotchas
  • CPU-bound in async: A tight loop in async def blocks the loop. Offload to threads or processes via loop.run_in_executor().
  • Missing await: Forgetting to await an async call returns a coroutine object, not the result — common bug.
  • Session per task: If your background task hits the DB, create a new session — don't reuse the request's session.
  • BackgroundTasks don't run in tests: They do with TestClient (synchronously) but verify behavior explicitly.
import httpx
from fastapi import BackgroundTasks

# ── async endpoint (use for I/O-bound work) ──
@app.get("/external")
async def fetch_external():
    async with httpx.AsyncClient() as client:
        resp = await client.get("https://api.example.com/data")
    return resp.json()

# Use `async def` when you have `await` calls
# Use regular `def` for CPU-bound or sync DB calls
# FastAPI runs regular `def` in a thread pool automatically

# ── Background tasks ──
def send_email(email: str, message: str):
    # slow operation — runs AFTER response is sent
    print(f"Sending to {email}: {message}")

@app.post("/signup")
def signup(email: str, bg: BackgroundTasks):
    bg.add_task(send_email, email, "Welcome!")
    return {"message": "Signed up!"}  # returns immediately

16 File Uploads

What is itFile uploads in FastAPI use the File and UploadFile primitives from the fastapi package. They handle multipart/form-data requests — the same format HTML file inputs send. UploadFile is the recommended type: it's a thin wrapper around SpooledTemporaryFile that keeps small files in memory and spools large ones to disk, so you can handle multi-GB uploads without OOM. Requires python-multipart to be installed.
Syntax
  • async def upload(file: UploadFile) — single file, async-friendly.
  • async def upload(file: bytes = File(...)) — entire file in memory as bytes (small files only).
  • async def upload(files: list[UploadFile]) — multiple files.
  • file: UploadFile = File(..., description="Profile pic") — with metadata.
  • Properties: file.filename, file.content_type, file.size, await file.read(), await file.write(), await file.seek(0), await file.close().
Typical pipeline
  • Validate content type: Check file.content_type against an allow-list (image/jpeg, image/png).
  • Validate magic bytes: Never trust the header — use python-magic to sniff the actual bytes.
  • Limit size: Reject before spooling: if file.size > 10_000_000: raise HTTPException(413).
  • Stream to storage: boto3/aioboto3 to S3, GCS, Azure Blob — avoid saving to local disk in containers.
  • Generate unique names: f"{uuid4()}.{ext}" — never use the client-supplied filename directly (path traversal).
How it differs
  • vs Flask: request.files['file'] returns a FileStorage — synchronous, no spooling.
  • vs Django: request.FILES['file'] returns an UploadedFile with similar spooling behavior.
  • vs Express: Needs multer middleware; more config but flexible storage backends.
Common gotchas
  • Missing python-multipart: You get a cryptic 422; install fastapi[standard] or python-multipart.
  • bytes vs UploadFile: bytes = File(...) loads the entire file into memory — fatal for large uploads.
  • Swagger doesn't test multi-file well — use curl or Postman.
  • Max body size: Check your reverse proxy too (nginx client_max_body_size, cloud load balancer limits).
  • Filename sanitization: ../../etc/passwd — always use os.path.basename() or UUIDs.
Real-world examplesImage upload for profile pics, CSV import endpoints for bulk data, audio/video ingestion for ML models, document upload for e-signature apps. For large files, prefer presigned S3 URLs — let the client upload directly to S3 and just send you the final URL; your API never touches the bytes.
from fastapi import File, UploadFile

# ── Single file ──
@app.post("/upload")
async def upload(file: UploadFile):
    contents = await file.read()
    return {
        "filename": file.filename,
        "content_type": file.content_type,
        "size": len(contents)
    }

# ── Multiple files ──
@app.post("/upload-many")
async def upload_many(files: list[UploadFile]):
    return [{"name": f.filename} for f in files]

# ── Save to disk ──
@app.post("/upload-save")
async def save_file(file: UploadFile):
    with open(f"uploads/{file.filename}", "wb") as f:
        f.write(await file.read())
    return {"saved": file.filename}

17 WebSockets

What is itWebSockets are a protocol (RFC 6455) that upgrades an HTTP connection to a full-duplex, long-lived TCP connection where both client and server can push messages at any time. Unlike HTTP's request/response cycle, a WebSocket stays open until either side closes it — making it ideal for chat, live dashboards, collaborative editing, multiplayer games, and stock tickers. FastAPI inherits WebSocket support from Starlette: @app.websocket("/ws") and an async handler with websocket.accept(), websocket.receive_text(), websocket.send_text().
Handler structure
  • Accept: await websocket.accept() — completes the handshake.
  • Receive: data = await websocket.receive_text() (also receive_json, receive_bytes).
  • Send: await websocket.send_text(msg).
  • Loop: Usually a while True: loop wrapped in try / except WebSocketDisconnect.
  • Close: await websocket.close(code=1000) — RFC 6455 close codes (1000=normal, 1008=policy violation, 1011=server error).
Broadcasting to multiple clientsFastAPI doesn't ship a pub/sub primitive. Typical patterns:
  • In-process manager: A class holding set[WebSocket] and a broadcast() method — works for single-worker apps only.
  • Redis pub/sub: Use redis.asyncio and the broadcaster library for multi-worker broadcast.
  • Kafka / NATS: For high-throughput fanout across many workers/services.
How it differs
  • vs Server-Sent Events (SSE): SSE is server→client only, text only, over plain HTTP — simpler and works with HTTP/2 multiplexing. Use SSE when you don't need client→server messages.
  • vs long polling: Simulates real-time with repeated HTTP requests — wasteful, higher latency.
  • vs Socket.IO: Socket.IO adds rooms, reconnection, fallbacks (long-polling when WS is blocked). FastAPI has python-socketio if you want that.
  • vs Flask: Flask can't do real WebSockets without flask-sock/flask-socketio (worker-pinning). FastAPI/ASGI handles them natively.
Common gotchas
  • Auth on WebSocket: Browsers don't let JS set Authorization headers on WebSocket handshakes. Pass token as a query param (?token=...) or a cookie.
  • Forgetting accept(): Trying to send before accepting raises an error.
  • WebSocketDisconnect not caught: Leaks to stderr; wrap the loop in try/except.
  • Multi-worker broadcast: With gunicorn -w 4, each worker has its own connection set — in-process broadcasts miss clients on other workers.
  • Reverse proxy config: nginx needs Upgrade and Connection: upgrade headers forwarded; AWS ALB needs a listener rule.
  • Idle timeout: Many proxies kill idle connections after 60s — send application-level pings.
Real-world examplesFigma multiplayer cursors, Slack message delivery, Discord voice/chat, Trading platforms (live price feeds), Google Docs collaborative editing, Notion sync, ChatGPT streaming (uses SSE, but same idea).
from fastapi import WebSocket, WebSocketDisconnect

@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
    await ws.accept()
    try:
        while True:
            data = await ws.receive_text()
            await ws.send_text(f"Echo: {data}")
    except WebSocketDisconnect:
        print("Client disconnected")

# ── Chat room (multiple clients) ──
class ConnectionManager:
    def __init__(self):
        self.connections: list[WebSocket] = []

    async def connect(self, ws: WebSocket):
        await ws.accept()
        self.connections.append(ws)

    def disconnect(self, ws: WebSocket):
        self.connections.remove(ws)

    async def broadcast(self, message: str):
        for conn in self.connections:
            await conn.send_text(message)

manager = ConnectionManager()

18 Project Structure

What is itProject structure is how you organize files and folders as your FastAPI app grows beyond a single main.py. FastAPI is completely unopinionated here — you can do whatever works — but the community has converged on two dominant styles: layered (split by technical role: routes/, models/, schemas/, services/) and domain-driven (split by business feature: users/, orders/, payments/, each with their own models/schemas/routes). The right choice depends on team size and app complexity.
Common structures
  • Flat (tiny apps): main.py, models.py, schemas.py, database.py — fine for <10 endpoints.
  • Layered: app/routers/, app/models/, app/schemas/, app/crud/, app/core/ (config, security), app/db/. Matches the official FastAPI tutorial.
  • Domain-driven / "package by feature": app/users/{router,schemas,models,service}.py, app/orders/.... Scales better for large teams.
  • Clean architecture / hexagonal: domain/, application/, infrastructure/, interface/ — maximum decoupling, maximum ceremony.
APIRouter splittingThe key mechanism is APIRouter: each feature module defines its own router with a prefix and tags, and main.py wires them together with app.include_router(users.router, prefix="/api/v1"). This enables:
  • Separation of route files (no giant main.py).
  • Versioning: /api/v1 vs /api/v2.
  • Per-router dependencies (e.g., all admin routes require auth).
  • Feature toggles (conditionally include routers).
Config & settingsUse pydantic-settings: class Settings(BaseSettings): database_url: str; model_config = SettingsConfigDict(env_file=".env"). Then settings = Settings() at import time. Inject via DI: def get_settings() -> Settings with @lru_cache. Keep secrets in env vars, never in git — use .env.example to document what's needed.
How it differs
  • vs Django: Django enforces app structure (apps.py, models.py, views.py, urls.py) with manage.py startapp. FastAPI has no scaffolding.
  • vs Rails: Rails has extreme convention-over-configuration (MVC folder layout). FastAPI is the opposite — you choose.
  • vs NestJS: NestJS mandates modules, controllers, services, providers — very structured. FastAPI is lightweight in comparison.
Best practices
  • Avoid circular imports by keeping models and schemas in separate files.
  • Put the FastAPI app instance in main.py or app/main.py; wire routers there.
  • Use pyproject.toml with ruff, black, mypy, pytest configured.
  • Use src/ layout if you publish as a package.
  • Mirror test folder structure under tests/.
  • Pin dependencies with uv or pip-tools for reproducible builds.

Production Architecture

In production, a FastAPI app sits behind a reverse proxy (nginx) that handles SSL, static files, and load balancing. The app runs via gunicorn (process manager) spawning multiple uvicorn workers. The database is a separate service.

# How a request flows in production:
#
# Client → nginx (SSL, rate limit) → gunicorn (process manager)
#            │                            │
#            │                     ┌──────┼──────┐
#            │                     │      │      │
#            │                  worker  worker  worker  (uvicorn)
#            │                     │      │      │
#            │                     └──────┼──────┘
#            │                            │
#            │                      PostgreSQL / Redis
#
# Run command:
# gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
#                        │         │
#                    4 workers   ASGI worker class

Full Folder Structure

The complete layout for a production FastAPI app — models, schemas, routers, services, and tests all separated.

my-api/ │ ├── app/ # all application code lives here │ ├── __init__.py # makes app/ a Python package │ ├── main.py # FastAPI app instance, startup, routers │ ├── config.py # env vars, settings (pydantic-settings) │ ├── database.py # SQLAlchemy engine, session, Base class │ ├── dependencies.py # shared deps: get_db, get_current_user │ │ │ ├── models/ # SQLAlchemy ORM models (DB tables) │ │ ├── __init__.py # re-exports all models │ │ ├── user.py # User table │ │ └── post.py # Post table │ │ │ ├── schemas/ # Pydantic models (request/response shapes) │ │ ├── __init__.py │ │ ├── user.py # UserCreate, UserResponse, UserUpdate │ │ └── post.py # PostCreate, PostResponse │ │ │ ├── routers/ # API route handlers (thin layer) │ │ ├── __init__.py │ │ ├── users.py # /users endpoints │ │ ├── posts.py # /posts endpoints │ │ └── auth.py # /login, /register, /me │ │ │ ├── services/ # business logic (reusable, testable) │ │ ├── __init__.py │ │ ├── user_service.py # create_user, get_user, etc. │ │ └── auth_service.py # hash_password, create_token, etc. │ │ │ └── utils/ # helpers that don't fit elsewhere │ ├── __init__.py │ └── email.py # send_email, etc. │ ├── alembic/ # DB migration scripts (auto-generated) │ ├── env.py # migration config │ └── versions/ # migration files │ └── 001_create_users.py │ ├── tests/ # all tests mirror app/ structure │ ├── conftest.py # fixtures: test client, test DB │ ├── test_users.py │ └── test_auth.py │ ├── alembic.ini # alembic config (DB URL) ├── pyproject.toml # project metadata, deps, tool config ├── requirements.txt # pip freeze output ├── Dockerfile # container build ├── docker-compose.yml # app + postgres + redis ├── .env # secrets (NEVER commit this) ├── .env.example # template for .env (commit this) └── .gitignore
Key principle: Routers are thin — they only handle HTTP (parse request, call service, return response). Services hold the business logic. Models define the DB. Schemas define the API contract. This separation makes everything testable and swappable.

Every File Explained

A deep dive into what each file does, why it exists, and the actual code that goes inside it.

app/config.py — Environment & Settings

# Reads from .env file or environment variables.
# Single source of truth for ALL configuration.
# Never hardcode secrets — always use this.

from pydantic_settings import BaseSettings
from functools import lru_cache

class Settings(BaseSettings):
    # ── App ──
    app_name: str = "My API"
    debug: bool = False                 # True in dev, False in prod
    api_version: str = "v1"

    # ── Database ──
    database_url: str                    # required! no default = must be set

    # ── Auth ──
    secret_key: str                      # for JWT signing
    access_token_expire_minutes: int = 30

    # ── CORS ──
    allowed_origins: list[str] = ["http://localhost:3000"]

    # ── External services ──
    redis_url: str = "redis://localhost:6379"
    smtp_host: str | None = None

    # pydantic-settings reads from .env file automatically
    model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}


# Cache it — settings are read once, reused everywhere
@lru_cache
def get_settings() -> Settings:
    return Settings()

app/database.py — Database Connection

# Sets up SQLAlchemy engine, session factory, and Base class.
# Every file that talks to the DB imports from here.

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from app.config import get_settings

settings = get_settings()

# Engine = connection pool to the database
# pool_pre_ping=True: test connections before using (handles DB restarts)
engine = create_engine(
    settings.database_url,
    pool_pre_ping=True,
    # pool_size=20,         # max persistent connections
    # max_overflow=10,      # extra connections under load
)

# SessionLocal = factory that creates DB sessions
# autocommit=False: you control when to commit
# autoflush=False: don't auto-sync to DB (explicit is better)
SessionLocal = sessionmaker(
    bind=engine,
    autocommit=False,
    autoflush=False,
)

# Base = all ORM models inherit from this
class Base(DeclarativeBase):
    pass

app/dependencies.py — Shared Dependencies

# Functions injected via Depends() into route handlers.
# Keeps routers clean — they don't know how DB or auth works.

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from jose import jwt, JWTError
from app.database import SessionLocal
from app.config import get_settings
from app.models.user import User

settings = get_settings()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")


# ── Database session dependency ──
# yield = the session is given to the route handler,
# then ALWAYS closed in finally (even if route crashes)
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()


# ── Current user dependency ──
# Extracts and validates the JWT token from the Authorization header.
# If invalid → 401. If valid → returns the User object.
def get_current_user(
    token: str = Depends(oauth2_scheme),   # extract Bearer token
    db: Session = Depends(get_db),          # get DB session
) -> User:
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
        user_id: int = payload.get("sub")
        if user_id is None:
            raise credentials_exception
    except JWTError:
        raise credentials_exception

    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        raise credentials_exception
    return user


# ── Admin-only dependency (chains on get_current_user) ──
def get_admin_user(user: User = Depends(get_current_user)) -> User:
    if user.role != "admin":
        raise HTTPException(status_code=403, detail="Admin access required")
    return user

app/models/user.py — Database Table

# SQLAlchemy ORM model = Python class that maps to a DB table.
# Each attribute = a column. This is your source of truth for the DB schema.

from sqlalchemy import Column, Integer, String, Boolean, DateTime, func
from sqlalchemy.orm import relationship
from app.database import Base


class User(Base):
    __tablename__ = "users"   # actual table name in the DB

    # ── Columns ──
    id = Column(Integer, primary_key=True, index=True)
    name = Column(String(100), nullable=False)
    email = Column(String(255), unique=True, index=True, nullable=False)
    hashed_password = Column(String(255), nullable=False)
    role = Column(String(20), default="user")           # "user" or "admin"
    is_active = Column(Boolean, default=True)

    # ── Timestamps (auto-set by DB) ──
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), onupdate=func.now())

    # ── Relationships ──
    posts = relationship("Post", back_populates="author", cascade="all, delete")

    def __repr__(self):
        return f"User(id={self.id}, email={self.email})"

app/schemas/user.py — API Contracts (Pydantic)

# Pydantic models define the SHAPE of your API.
# Separate from ORM models! Schema = what the client sees.
# Model = what the database stores.

from pydantic import BaseModel, Field, EmailStr
from datetime import datetime


# ── What the client SENDS to create a user ──
class UserCreate(BaseModel):
    name: str = Field(min_length=2, max_length=100)
    email: EmailStr                           # auto-validates email format
    password: str = Field(min_length=8)       # plain text — we hash it


# ── What the client SENDS to update a user ──
class UserUpdate(BaseModel):
    name: str | None = None                  # all optional for PATCH
    email: EmailStr | None = None


# ── What the client RECEIVES back ──
# Notice: no password field! Never expose passwords.
class UserResponse(BaseModel):
    id: int
    name: str
    email: str
    role: str
    is_active: bool
    created_at: datetime

    # from_attributes = True lets Pydantic read from ORM objects
    # (SQLAlchemy models use .email not ["email"])
    model_config = {"from_attributes": True}


# ── Auth schemas ──
class Token(BaseModel):
    access_token: str
    token_type: str = "bearer"

app/services/user_service.py — Business Logic

# All the "brain" of your app lives here.
# Services are pure logic — no HTTP, no request/response objects.
# This makes them reusable (CLI, workers, tests) and easy to test.

from sqlalchemy.orm import Session
from fastapi import HTTPException
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
from app.services.auth_service import hash_password


def get_user_by_id(db: Session, user_id: int) -> User:
    """Fetch user or raise 404."""
    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    return user


def get_user_by_email(db: Session, email: str) -> User | None:
    """Fetch user by email (for login, duplicate checks)."""
    return db.query(User).filter(User.email == email).first()


def list_users(db: Session, skip: int = 0, limit: int = 20) -> list[User]:
    """Paginated user list."""
    return db.query(User).offset(skip).limit(limit).all()


def create_user(db: Session, data: UserCreate) -> User:
    """Create a new user. Hashes password, checks for duplicates."""

    # Check if email already exists
    if get_user_by_email(db, data.email):
        raise HTTPException(status_code=400, detail="Email already registered")

    # Create ORM object (hash the password!)
    user = User(
        name=data.name,
        email=data.email,
        hashed_password=hash_password(data.password),
    )

    # Add to DB session, commit, refresh to get auto-generated fields
    db.add(user)
    db.commit()
    db.refresh(user)         # now user.id and user.created_at are populated
    return user


def update_user(db: Session, user_id: int, data: UserUpdate) -> User:
    """Partial update — only change fields that were sent."""
    user = get_user_by_id(db, user_id)

    # model_dump(exclude_unset=True) only includes fields the client sent
    # so PATCH /users/1 {"name": "New"} only updates name, not email
    update_data = data.model_dump(exclude_unset=True)
    for field, value in update_data.items():
        setattr(user, field, value)

    db.commit()
    db.refresh(user)
    return user


def delete_user(db: Session, user_id: int) -> None:
    """Delete user or raise 404."""
    user = get_user_by_id(db, user_id)
    db.delete(user)
    db.commit()

app/services/auth_service.py — Auth Logic

# Password hashing and JWT token creation/verification.
# Completely independent of HTTP — can be used anywhere.

from passlib.context import CryptContext
from jose import jwt
from datetime import datetime, timedelta
from app.config import get_settings

settings = get_settings()

# bcrypt is the industry standard for password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def hash_password(password: str) -> str:
    """One-way hash. Can't reverse it — only verify."""
    return pwd_context.hash(password)


def verify_password(plain: str, hashed: str) -> bool:
    """Check if plain text matches the hash."""
    return pwd_context.verify(plain, hashed)


def create_access_token(user_id: int) -> str:
    """Create a JWT token with user_id and expiration."""
    payload = {
        "sub": user_id,                          # subject = who this token is for
        "exp": datetime.utcnow() + timedelta(      # when it expires
            minutes=settings.access_token_expire_minutes
        ),
        "iat": datetime.utcnow(),                  # issued at
    }
    return jwt.encode(payload, settings.secret_key, algorithm="HS256")

app/routers/users.py — Route Handlers

# Routers are THIN. They only:
# 1. Define the HTTP method + path
# 2. Declare dependencies (DB, auth)
# 3. Call the service layer
# 4. Return the response
# NO business logic here!

from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from app.dependencies import get_db, get_current_user
from app.schemas.user import UserCreate, UserUpdate, UserResponse
from app.services import user_service
from app.models.user import User

# prefix = all routes here start with /users
# tags = group in Swagger UI docs
router = APIRouter(prefix="/users", tags=["Users"])


@router.get("/", response_model=list[UserResponse])
def list_users(
    skip: int = 0,
    limit: int = 20,
    db: Session = Depends(get_db),              # injected DB session
):
    """Get paginated list of users."""
    return user_service.list_users(db, skip, limit)


@router.get("/{user_id}", response_model=UserResponse)
def get_user(user_id: int, db: Session = Depends(get_db)):
    """Get a single user by ID."""
    return user_service.get_user_by_id(db, user_id)


@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(data: UserCreate, db: Session = Depends(get_db)):
    """Register a new user."""
    return user_service.create_user(db, data)


@router.patch("/{user_id}", response_model=UserResponse)
def update_user(
    user_id: int,
    data: UserUpdate,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),  # must be logged in
):
    """Update user (partial). Requires authentication."""
    return user_service.update_user(db, user_id, data)


@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
    user_id: int,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    """Delete a user. Requires authentication."""
    user_service.delete_user(db, user_id)

app/routers/auth.py — Auth Routes

from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.dependencies import get_db, get_current_user
from app.schemas.user import UserCreate, UserResponse, Token
from app.services import user_service, auth_service

router = APIRouter(prefix="/auth", tags=["Auth"])


@router.post("/register", response_model=UserResponse, status_code=201)
def register(data: UserCreate, db: Session = Depends(get_db)):
    """Public registration endpoint."""
    return user_service.create_user(db, data)


@router.post("/login", response_model=Token)
def login(
    form: OAuth2PasswordRequestForm = Depends(),  # username + password form
    db: Session = Depends(get_db),
):
    """Login → returns JWT token."""

    # 1. Find user by email (OAuth2 form uses "username" field)
    user = user_service.get_user_by_email(db, form.username)
    if not user:
        raise HTTPException(status_code=401, detail="Invalid credentials")

    # 2. Verify password
    if not auth_service.verify_password(form.password, user.hashed_password):
        raise HTTPException(status_code=401, detail="Invalid credentials")

    # 3. Create and return token
    token = auth_service.create_access_token(user.id)
    return {"access_token": token, "token_type": "bearer"}


@router.get("/me", response_model=UserResponse)
def me(current_user = Depends(get_current_user)):
    """Get the currently logged-in user."""
    return current_user

app/main.py — App Entry Point

# This is the root of your application.
# It creates the FastAPI instance, wires up routers,
# middleware, exception handlers, and startup/shutdown events.

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from app.config import get_settings
from app.database import engine, Base
from app.routers import users, auth

settings = get_settings()


# ── Startup / Shutdown events ──
@asynccontextmanager
async def lifespan(app: FastAPI):
    # ── STARTUP: runs once when the app starts ──
    print("Creating database tables...")
    Base.metadata.create_all(bind=engine)   # create tables if not exist
    yield
    # ── SHUTDOWN: runs when the app stops ──
    print("Shutting down...")


# ── Create the FastAPI app ──
app = FastAPI(
    title=settings.app_name,
    version=settings.api_version,
    docs_url="/docs",          # Swagger UI
    redoc_url="/redoc",        # ReDoc
    lifespan=lifespan,
)


# ── Middleware ──
app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.allowed_origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


# ── Include routers ──
# Each router adds its own set of endpoints
app.include_router(auth.router)     # /auth/login, /auth/register, /auth/me
app.include_router(users.router)    # /users, /users/{id}


# ── Health check (every prod app needs this) ──
@app.get("/health", tags=["Health"])
def health():
    return {"status": "ok"}

tests/conftest.py — Test Setup

# Fixtures shared across ALL tests.
# Creates a separate test database so tests don't touch prod data.

import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.main import app
from app.database import Base
from app.dependencies import get_db

# Use SQLite in-memory for tests (fast, disposable)
TEST_DB_URL = "sqlite:///./test.db"
engine = create_engine(TEST_DB_URL, connect_args={"check_same_thread": False})
TestSession = sessionmaker(bind=engine)


@pytest.fixture(autouse=True)
def setup_db():
    """Create tables before each test, drop after."""
    Base.metadata.create_all(bind=engine)
    yield
    Base.metadata.drop_all(bind=engine)


@pytest.fixture
def db():
    """Provide a clean DB session for each test."""
    session = TestSession()
    try:
        yield session
    finally:
        session.close()


@pytest.fixture
def client(db):
    """Test client that uses the test DB instead of prod DB."""
    def override_get_db():
        yield db

    # Swap the real DB dependency with our test DB
    app.dependency_overrides[get_db] = override_get_db
    with TestClient(app) as c:
        yield c
    app.dependency_overrides.clear()

.env / .env.example — Environment Variables

# ── .env (NEVER commit this — it has real secrets) ──
DATABASE_URL=postgresql://myuser:mypassword@localhost:5432/mydb
SECRET_KEY=super-secret-jwt-key-change-this-in-production
DEBUG=false
ALLOWED_ORIGINS=["https://myapp.com","https://www.myapp.com"]
REDIS_URL=redis://localhost:6379

# ── .env.example (commit this — it's a template) ──
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
SECRET_KEY=change-me
DEBUG=true
ALLOWED_ORIGINS=["http://localhost:3000"]

docker-compose.yml — Full Stack

# Runs your API + PostgreSQL + Redis in one command:
# docker compose up --build

version: "3.9"

services:
  api:
    build: .
    ports:
      - "8000:8000"
    env_file: .env
    depends_on:
      - db
      - redis
    volumes:
      - .:/app              # mount code for hot reload in dev
    command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: myuser
      POSTGRES_PASSWORD: mypassword
      POSTGRES_DB: mydb
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data    # persist data

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  pgdata:                    # named volume so data survives restarts

Dockerfile — Production Build

# Multi-stage build: small final image, no dev dependencies

FROM python:3.12-slim as base

# Don't write .pyc files, unbuffered output (for logging)
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

# Install deps first (cached if requirements.txt doesn't change)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy app code
COPY . .

# Non-root user (security best practice)
RUN adduser --disabled-password --no-create-home appuser
USER appuser

# Production server: gunicorn + uvicorn workers
# Workers = 2 * CPU cores + 1
CMD ["gunicorn", "app.main:app", \
     "-w", "4", \
     "-k", "uvicorn.workers.UvicornWorker", \
     "--bind", "0.0.0.0:8000"]

How Data Flows Through the Layers

Follow a single request from client to database and back — see how each layer handles its part.

# POST /users {"name": "Yatin", "email": "y@dev.com", "password": "secret123"}
#
# 1. FastAPI receives the request
# 2. Pydantic validates the body → UserCreate schema
#    (wrong type? missing field? → auto 422 error)
# 3. Router handler calls: user_service.create_user(db, data)
# 4. Service layer:
#    - Checks if email exists (business rule)
#    - Hashes the password (security)
#    - Creates User ORM object
#    - db.add() + db.commit() + db.refresh()
# 5. Returns User ORM object back to router
# 6. FastAPI serializes it through response_model=UserResponse
#    (filters out hashed_password, formats dates)
# 7. Client receives: {"id": 1, "name": "Yatin", "email": "y@dev.com", ...}
#
# Request:  Client → Router → Service → Database
# Response: Database → Service → Router → Pydantic → Client

19 Testing

What is itTesting a FastAPI app means writing automated checks that instantiate your app, send requests, and assert responses. FastAPI ships with TestClient — a subclass of httpx.Client that talks to your ASGI app in-process, no real HTTP server needed. You pair it with pytest (the de-facto Python test runner), pytest fixtures for setup/teardown, and dependency overrides to swap real DBs for in-memory ones.
Core tools
  • TestClient(app) — sync client, great for most tests.
  • AsyncClient(transport=ASGITransport(app=app)) — for testing async flows (httpx + asgi-lifespan).
  • app.dependency_overrides[get_db] = lambda: test_db — swap dependencies without monkey-patching.
  • pytest fixtures: @pytest.fixture for setup/teardown; scopes: function, module, session.
  • pytest-asyncio: for async def test_... functions.
  • Faker / factory-boy: for test data generation.
  • freezegun: to freeze datetime.now().
  • respx / httpx_mock: to mock outbound HTTP calls.
Test pyramid
  • Unit tests: Pure functions, business logic, Pydantic validators — fast, many.
  • Integration tests: Routes against a real (or dockerized) DB — medium number.
  • End-to-end tests: Run the full app in docker-compose, hit real HTTP, use Playwright/Cypress for the frontend — slow, few.
Database strategies
  • SQLite in-memory: Fastest, but subtly different from Postgres (no JSONB, different locking).
  • Docker Postgres: Closest to prod; use testcontainers-python for isolation.
  • Transaction rollback: Wrap each test in a transaction and roll it back — very fast, no need to recreate schema.
  • Factories: factory-boy or custom helpers for creating test users, orders, etc.
How it differs
  • vs Flask: Flask has app.test_client() — conceptually identical.
  • vs Django: Django's TestCase wraps each test in a transaction automatically; DRF's APIClient is similar to TestClient.
  • vs Express: Use supertest + Jest/Mocha.
  • vs Spring: @SpringBootTest + MockMvc — heavier setup, slower tests.
Common gotchas
  • Test DB not isolated: Tests pollute each other — use transactions or recreate per test.
  • Dependency overrides leak: Clear with app.dependency_overrides.clear() in teardown.
  • Async mixing: Can't use TestClient (sync) on async routes that use asyncio.current_task() — use AsyncClient.
  • Startup/shutdown events: TestClient runs them automatically if used as a context manager: with TestClient(app) as client: ....
Coverage goalsAim for ~80% line coverage on business logic (pytest --cov) but don't chase 100% — coverage is a floor, not a ceiling. Focus tests on: edge cases, error paths, security (authz), and data shapes. Use mutation testing (mutmut) for the critical paths.
# pip install pytest httpx
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_root():
    resp = client.get("/")
    assert resp.status_code == 200
    assert resp.json() == {"message": "Hello, World!"}

def test_create_user():
    resp = client.post("/users", json={
        "name": "Yatin",
        "email": "y@dev.com",
        "age": 25
    })
    assert resp.status_code == 201
    data = resp.json()
    assert data["name"] == "Yatin"

def test_not_found():
    resp = client.get("/users/9999")
    assert resp.status_code == 404

def test_validation_error():
    resp = client.post("/users", json={"name": 123})
    assert resp.status_code == 422

# ── Async testing ──
import pytest
from httpx import AsyncClient, ASGITransport

@pytest.mark.anyio
async def test_async():
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as ac:
        resp = await ac.get("/")
        assert resp.status_code == 200

# ── Override dependencies in tests ──
def override_get_db():
    db = TestSessionLocal()
    try:
        yield db
    finally:
        db.close()

app.dependency_overrides[get_db] = override_get_db

20 Deployment

What is itDeployment is getting your FastAPI app from your laptop to a production server where real users can hit it. In 2024 this almost always means: Docker imagecontainer registryorchestrator (Kubernetes, ECS, Cloud Run, Fly.io, Railway) → load balancer with TLS. FastAPI's key deployment requirement is an ASGI server like uvicorn or gunicorn with the uvicorn worker class.
Process model
  • uvicorn main:app --workers 4 — uvicorn's built-in multi-worker mode (since 0.15+).
  • gunicorn -k uvicorn.workers.UvicornWorker -w 4 main:app — gunicorn as process manager, each worker runs uvicorn. Industry standard because of graceful shutdown and robust signal handling.
  • Worker count: rule of thumb (2 × CPU) + 1 for CPU-bound, fewer for async I/O-bound.
  • One worker in K8s: Let Kubernetes horizontally scale pods instead — simpler mental model.
Dockerfile essentials
  • Use multi-stage builds to keep the final image small.
  • Base on python:3.12-slim; avoid alpine (musl compatibility issues with psycopg2, numpy).
  • Install deps BEFORE copying code for Docker layer caching.
  • Run as a non-root user.
  • Set PYTHONUNBUFFERED=1 so logs show up immediately.
  • Expose port 8000; let the orchestrator map it.
Platforms
  • Render / Railway / Fly.io: Simplest — push code, get URL.
  • Google Cloud Run: Serverless containers, scale to zero, pay per request.
  • AWS ECS Fargate: Managed containers on AWS, integrates with ALB.
  • AWS Lambda: Via Mangum adapter — fastapi on Lambda. Cold starts ~500ms–2s.
  • Kubernetes: Full control, most complex. Use Helm charts.
  • VPS (DigitalOcean, Hetzner, Linode): Classic nginx + systemd + gunicorn.
Essential production concerns
  • Health checks: /healthz (liveness) and /readyz (readiness, checks DB).
  • Structured logging: JSON logs via structlog or python-json-logger.
  • Observability: OpenTelemetry for traces, Prometheus for metrics, Sentry for errors.
  • Secrets management: AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault — never env files in git.
  • Graceful shutdown: Handle SIGTERM, drain in-flight requests, close DB connections.
  • Reverse proxy: nginx/Caddy/Traefik for TLS termination, gzip, rate limiting.
Common gotchas
  • Forgetting --proxy-headers: Behind a load balancer, uvicorn doesn't trust X-Forwarded-* unless told to — client_ip becomes the LB IP.
  • Single-worker deploys: 1 worker = 1 blocked request stalls everything — always run multiple workers or pods.
  • Memory leaks from ML models: Load once at startup in lifespan, not per request.
  • Database pool exhaustion: Each worker opens its own pool; N_workers × pool_size must be < DB max_connections.

Dockerfile

Package your FastAPI app into a Docker container for consistent deployment anywhere.

FROM python:3.12-slim

WORKDIR /app

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

COPY . .

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Production Checklist

Everything you need to do before deploying to production — security, performance, and reliability.

pydantic-settings for Config

Type-safe configuration management — reads from .env files and environment variables with full validation.

# pip install pydantic-settings
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    secret_key: str
    debug: bool = False
    allowed_origins: list[str] = ["http://localhost:3000"]

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

settings = Settings()

# .env file:
# DATABASE_URL=postgresql://user:pass@localhost/mydb
# SECRET_KEY=super-secret-key-here

21 Redis

What is itRedis (REmote DIctionary Server) is an in-memory data store created by Salvatore Sanfilippo in 2009, now maintained by Redis Ltd. It speaks its own simple text protocol (RESP) over TCP and keeps all data in RAM for extreme speed — typical operations take <1ms. Redis is the Swiss Army knife of modern backends: cache, session store, rate limiter, message broker, pub/sub bus, distributed lock, leaderboard, full-text index, and more. FastAPI uses redis-py (sync) or redis.asyncio (async) — both from the same redis pip package.
Data structuresRedis isn't just a key-value store — it has rich types:
  • Strings: SET, GET, INCR, INCRBY, APPEND. Binary-safe up to 512MB.
  • Lists: LPUSH, RPUSH, LPOP, BRPOP — doubly linked lists, used as queues.
  • Hashes: HSET, HGET, HGETALL — object storage.
  • Sets: SADD, SMEMBERS, SINTER — uniqueness, tag sets.
  • Sorted sets (ZSET): ZADD, ZRANGE, ZRANGEBYSCORE — leaderboards, priority queues, time series.
  • Streams: XADD, XREADGROUP — Kafka-like append-only log with consumer groups.
  • Pub/Sub: SUBSCRIBE/PUBLISH — fire-and-forget messaging.
  • Geo: GEOADD, GEOSEARCH — geospatial indexing.
  • HyperLogLog: PFADD, PFCOUNT — approximate cardinality.
  • Bitmaps: SETBIT, BITCOUNT — efficient user flags.
Use cases with FastAPI
  • Caching: fastapi-cache2 decorates routes; or manual SET key value EX 60.
  • Rate limiting: slowapi or fastapi-limiter (token bucket in Redis).
  • Session store: Instead of JWTs for mutable sessions.
  • Task queues: RQ, Arq, Celery all use Redis as broker.
  • Distributed locks: SET key val NX PX 5000 — Redlock pattern.
  • Real-time pub/sub: Broadcast WebSocket messages across workers.
How it differs
  • vs Memcached: Memcached is simpler (strings only, no persistence, LRU eviction). Redis has richer types, optional persistence, replication, clustering.
  • vs Postgres: Postgres is disk-based ACID OLTP; Redis is in-memory, eventually consistent. Use both — Postgres for source of truth, Redis for hot reads.
  • vs DynamoDB: DynamoDB is managed, autoscaled, strongly consistent — but slower (~5-10ms vs Redis's <1ms) and pay per request.
  • vs Kafka: Redis Streams is much smaller scale than Kafka; good for within-app messaging, not log ingestion.
Performance & persistenceRedis handles 100k+ ops/sec on a single core. Persistence options: RDB (periodic snapshots, fast restart) and AOF (append-only log, more durable but slower). For caching use cases, disable persistence entirely. For critical data, enable AOF with appendfsync everysec.
Common gotchas
  • Forgotten TTL: Keys without EX/EXPIRE stay forever — memory leak.
  • KEYS * in prod: O(N) blocking; use SCAN.
  • Cache stampede: Popular key expires, 100 requests hit DB simultaneously — use locking or stale-while-revalidate.
  • Serialization: Redis only stores strings/bytes — JSON-encode objects, or use msgpack/pickle (with caution).
  • Sync redis in async route: Blocks the loop — use redis.asyncio.
  • Memory limit: Configure maxmemory + maxmemory-policy allkeys-lru or Redis OOMs.
Real-world examplesTwitter uses Redis for timeline caching, GitHub for session storage, Stack Overflow for page cache, Instagram for counters, Airbnb for rate limiting. Every large production system eventually has Redis somewhere.

Redis is an in-memory data store — blazing fast key-value storage used for caching, sessions, rate limiting, and real-time pub/sub. It sits between your API and your database to speed things up.

# Install
pip install redis[hiredis]

# redis     = Python Redis client (async support built-in)
# hiredis   = C parser for faster responses (optional but recommended)

Connection Setup

Create a Redis client as a dependency so every route can use it — connect on startup, disconnect on shutdown.

# app/redis.py
import redis.asyncio as redis
from app.config import settings

# Connection pool — reuses connections instead of opening new ones
pool = redis.ConnectionPool.from_url(
    settings.redis_url,      # "redis://localhost:6379"
    max_connections=10,
    decode_responses=True,   # return strings, not bytes
)

async def get_redis():
    client = redis.Redis(connection_pool=pool)
    try:
        yield client
    finally:
        await client.aclose()
# app/main.py
from contextlib import asynccontextmanager
from app.redis import pool

@asynccontextmanager
async def lifespan(app):
    # Startup — pool is already created
    yield
    # Shutdown — close all connections
    await pool.aclose()

app = FastAPI(lifespan=lifespan)

Caching

Store expensive query results in Redis — avoid hitting the database on every request. Set a TTL so stale data expires automatically.

import json
from fastapi import APIRouter, Depends
from app.redis import get_redis

router = APIRouter()

@router.get("/users/{user_id}")
async def get_user(user_id: int, r = Depends(get_redis), db = Depends(get_db)):
    # 1. Check cache first
    cache_key = f"user:{user_id}"
    cached = await r.get(cache_key)

    if cached:
        return json.loads(cached)   # cache HIT — no DB query!

    # 2. Cache MISS — query database
    user = db.query(User).get(user_id)
    if not user:
        raise HTTPException(404)

    # 3. Store in cache (expire after 5 minutes)
    user_data = {"id": user.id, "name": user.name, "email": user.email}
    await r.set(cache_key, json.dumps(user_data), ex=300)

    return user_data


# Invalidate cache when data changes
@router.put("/users/{user_id}")
async def update_user(user_id: int, data: UserUpdate, r = Depends(get_redis)):
    # ... update in DB ...
    await r.delete(f"user:{user_id}")   # clear stale cache
    return updated_user
Cache-aside pattern: Check cache → miss → query DB → store in cache. This is the most common caching strategy. Always invalidate (delete) the cache when the underlying data changes.

Rate Limiting

Prevent abuse by limiting how many requests a client can make — Redis tracks request counts with auto-expiring keys.

from fastapi import Request, HTTPException

async def rate_limit(
    request: Request,
    r = Depends(get_redis),
    limit: int = 100,        # max requests
    window: int = 60,         # per 60 seconds
):
    client_ip = request.client.host
    key = f"rate:{client_ip}"

    # Increment counter, set expiry on first request
    current = await r.incr(key)
    if current == 1:
        await r.expire(key, window)

    if current > limit:
        raise HTTPException(
            status_code=429,
            detail=f"Rate limit exceeded. Try again in {await r.ttl(key)}s"
        )

# Apply to routes
@router.get("/search", dependencies=[Depends(rate_limit)])
async def search(q: str):
    ...

Session Storage

Store user sessions in Redis instead of the database — faster reads and automatic expiry for inactive sessions.

import uuid

async def create_session(r, user_id: int, ttl: int = 3600) -> str:
    session_id = str(uuid.uuid4())
    await r.hset(f"session:{session_id}", mapping={
        "user_id": user_id,
        "created": str(datetime.utcnow()),
    })
    await r.expire(f"session:{session_id}", ttl)  # auto-delete after 1 hour
    return session_id

async def get_session(r, session_id: str) -> dict | None:
    data = await r.hgetall(f"session:{session_id}")
    return data or None

async def delete_session(r, session_id: str):
    await r.delete(f"session:{session_id}")

Pub/Sub — Real-Time Events

Publish events from one part of your app and subscribe to them from another — great for notifications and live updates.

# Publisher — send events from any route
@router.post("/orders")
async def create_order(order: OrderCreate, r = Depends(get_redis)):
    # ... save to DB ...
    # Publish event to "orders" channel
    await r.publish("orders", json.dumps({
        "event": "order_created",
        "order_id": order_id,
        "user_id": order.user_id,
    }))
    return {"id": order_id}

# Subscriber — listen via WebSocket
@router.websocket("/ws/orders")
async def order_feed(ws: WebSocket, r = Depends(get_redis)):
    await ws.accept()
    pubsub = r.pubsub()
    await pubsub.subscribe("orders")

    try:
        async for msg in pubsub.listen():
            if msg["type"] == "message":
                await ws.send_text(msg["data"])
    finally:
        await pubsub.unsubscribe("orders")

Common Redis Commands

Quick reference for the Redis operations you'll use most often in FastAPI.

OperationCommandUse Case
Set valueawait r.set("key", "value", ex=300)Cache with 5min TTL
Get valueawait r.get("key")Read from cache
Deleteawait r.delete("key")Invalidate cache
Incrementawait r.incr("counter")Rate limiting, counters
Hash setawait r.hset("user:1", mapping={...})Store objects (sessions)
Hash get allawait r.hgetall("user:1")Read full object
Expireawait r.expire("key", 3600)Auto-delete after time
TTLawait r.ttl("key")Check remaining time
Existsawait r.exists("key")Check before querying DB
List pushawait r.lpush("queue", "task")Simple job queue
List popawait r.rpop("queue")Process next job
Publishawait r.publish("channel", "msg")Real-time events
Redis is not a database replacement. It's volatile memory — if Redis crashes, cached data is gone. Always have the real data in PostgreSQL. Redis just makes reads faster.

22 Kafka

What is itApache Kafka is a distributed, append-only, commit-log-based event streaming platform originally built at LinkedIn in 2011 and open-sourced via the Apache Software Foundation. Unlike traditional message brokers (RabbitMQ, ActiveMQ), Kafka stores messages as a durable, replayable log — consumers track their own position (offset) and can rewind or fast-forward. This makes it ideal for event sourcing, stream processing, and building "event-driven" architectures. Kafka can sustain millions of messages per second per cluster.
Core concepts
  • Topic: A named stream of messages — like a database table.
  • Partition: Topics are split into partitions for parallelism. Each partition is an ordered append-only log.
  • Producer: Publishes messages to a topic.
  • Consumer: Reads messages, tracks its offset per partition.
  • Consumer group: Multiple consumers sharing the work — partitions are distributed among group members.
  • Broker: A Kafka server. A cluster has many brokers; each partition has a leader and replicas.
  • Offset: The position of a message in a partition; consumers commit offsets to track progress.
  • Replication factor: Each partition is replicated N times (typically 3) for fault tolerance.
Python libraries for FastAPI
  • aiokafka: Async native, perfect for FastAPI — AIOKafkaProducer, AIOKafkaConsumer.
  • confluent-kafka-python: Wraps librdkafka (C), fastest, sync API.
  • kafka-python: Pure Python, simpler but slower.
  • faust / faust-streaming: Stream processing framework inspired by Kafka Streams.
How it differs
  • vs RabbitMQ: RabbitMQ is a traditional broker — messages are consumed and removed. Kafka retains messages (default 7 days). RabbitMQ has richer routing (direct, topic, fanout, headers exchanges); Kafka has simpler model but scales further.
  • vs Redis Streams: Redis Streams is similar in design but single-node (unless using Redis Cluster). Kafka is purpose-built for horizontal scale and multi-DC replication.
  • vs SQS/SNS: AWS managed, simpler, but no message ordering across shards, no replay.
  • vs NATS: NATS (JetStream) is lighter-weight, simpler to operate, but smaller ecosystem.
  • vs Pulsar: Apache Pulsar has tiered storage and multi-tenancy — newer challenger.
Use cases
  • Event sourcing: Every state change is an event; rebuild state by replaying.
  • Microservice communication: Decoupled async messaging between services.
  • Log aggregation: Ship all app logs to Kafka, then to Elasticsearch.
  • Change Data Capture (CDC): Stream database changes via Debezium.
  • Real-time analytics: Feed into Spark/Flink/ksqlDB for aggregations.
  • Audit trail: Immutable log of all actions.
Common gotchas
  • Ordering: Only guaranteed within a partition. If you need global order, use 1 partition (slow).
  • Consumer group rebalancing: Every time a consumer joins/leaves, partitions reshuffle — brief pauses.
  • At-least-once vs exactly-once: Default is at-least-once; exactly-once requires transactions and idempotent producers.
  • Large messages: Default max is 1MB; for bigger payloads store in S3 and put the URL in Kafka.
  • Schema evolution: Use Avro/Protobuf with Schema Registry to prevent breaking changes.
  • Dev setup: Running Kafka locally is heavy; use docker-compose with redpanda (Kafka-compatible) for a lighter alternative.
Real-world examplesLinkedIn (created it, 7 trillion+ messages/day), Uber (trip events), Netflix (streaming analytics), Airbnb (search indexing), Spotify (event pipeline), Robinhood (order events). Any large event-driven system runs Kafka or a close equivalent.

Apache Kafka is a distributed event streaming platform — it lets services communicate by producing and consuming messages through topics. Unlike HTTP APIs, Kafka is asynchronous and durable — messages persist even if the consumer is down.

When to Use Kafka vs REST

Use REST for synchronous request-response. Use Kafka when services need to communicate without waiting for each other.

ScenarioUse RESTUse Kafka
Get user profileYesNo
Send welcome email after signupNoYes
Process paymentYesNo
Update analytics after purchaseNoYes
Sync data between servicesNoYes
Real-time activity feedNoYes
# Install
pip install aiokafka

# aiokafka = async Kafka client for Python (built on kafka-python)
# You also need a running Kafka broker (use Docker for local dev)
# docker-compose.yml — local Kafka setup
# services:
#   kafka:
#     image: confluentinc/cp-kafka:7.5.0
#     ports:
#       - "9092:9092"
#     environment:
#       KAFKA_NODE_ID: 1
#       KAFKA_PROCESS_ROLES: broker,controller
#       KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093
#       KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
#       KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093
#       KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
#       KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
#       CLUSTER_ID: "MkU3OEVBNTcwNTJENDM2Qk"

Core Concepts

Understand these four things and you understand Kafka — producers send messages, consumers read them, topics organize them, and consumer groups enable scaling.

# Kafka Architecture
#
# Producer ──→ Topic ──→ Consumer
#              (log)
#
# Topic:          A named category/feed of messages (like "orders", "emails")
# Partition:      Topic is split into partitions for parallelism
# Producer:       Sends messages TO a topic
# Consumer:       Reads messages FROM a topic
# Consumer Group: Multiple consumers sharing the work (each gets different partitions)
# Offset:         Position of a consumer in the topic (like a bookmark)
#
# Key difference from a queue:
# - Queue: message is deleted after being consumed
# - Kafka: message STAYS — multiple consumers can read it independently

Producer — Sending Events

Create a producer that connects on app startup and sends events whenever something happens in your API.

# app/kafka.py
from aiokafka import AIOKafkaProducer, AIOKafkaConsumer
import json

class KafkaProducerService:
    def __init__(self, bootstrap_servers: str = "localhost:9092"):
        self.producer = AIOKafkaProducer(
            bootstrap_servers=bootstrap_servers,
            value_serializer=lambda v: json.dumps(v).encode("utf-8"),
            key_serializer=lambda k: k.encode("utf-8") if k else None,
        )

    async def start(self):
        await self.producer.start()

    async def stop(self):
        await self.producer.stop()

    async def send(self, topic: str, value: dict, key: str = None):
        await self.producer.send_and_wait(topic, value=value, key=key)

kafka_producer = KafkaProducerService()
# app/main.py — connect producer on startup
from contextlib import asynccontextmanager
from app.kafka import kafka_producer

@asynccontextmanager
async def lifespan(app):
    await kafka_producer.start()     # connect on startup
    yield
    await kafka_producer.stop()      # disconnect on shutdown

app = FastAPI(lifespan=lifespan)
# Use in routes — fire and forget
@router.post("/orders")
async def create_order(order: OrderCreate, db = Depends(get_db)):
    # 1. Save to database (source of truth)
    db_order = Order(**order.dict())
    db.add(db_order)
    db.commit()

    # 2. Publish event to Kafka (async side effects)
    await kafka_producer.send(
        topic="orders",
        key=str(db_order.id),
        value={
            "event": "order_created",
            "order_id": db_order.id,
            "user_id": order.user_id,
            "total": order.total,
        }
    )

    return {"id": db_order.id, "status": "created"}

Consumer — Processing Events

Consumers run as background tasks that continuously listen for new messages and process them independently from the API.

# app/consumers/order_consumer.py
import asyncio
import json
from aiokafka import AIOKafkaConsumer

async def consume_orders():
    consumer = AIOKafkaConsumer(
        "orders",                       # topic to listen to
        bootstrap_servers="localhost:9092",
        group_id="email-service",       # consumer group name
        value_deserializer=lambda m: json.loads(m.decode("utf-8")),
        auto_offset_reset="earliest",   # start from beginning if no offset saved
    )
    await consumer.start()

    try:
        async for msg in consumer:
            event = msg.value
            print(f"Received: {event}")

            if event["event"] == "order_created":
                # Send confirmation email, update analytics, etc.
                await send_order_email(event["user_id"], event["order_id"])

    finally:
        await consumer.stop()
# app/main.py — run consumer as background task
from app.consumers.order_consumer import consume_orders

@asynccontextmanager
async def lifespan(app):
    await kafka_producer.start()

    # Start consumer in background (runs alongside the API)
    consumer_task = asyncio.create_task(consume_orders())

    yield

    # Shutdown
    consumer_task.cancel()
    await kafka_producer.stop()

app = FastAPI(lifespan=lifespan)

Event-Driven Pattern

One event, multiple consumers — each service reacts independently. The producer doesn't need to know who's listening.

# When a user signs up, multiple things need to happen:
#
# WITHOUT Kafka (tightly coupled):
# @router.post("/signup")
# async def signup(user):
#     save_to_db(user)             # 1. save
#     await send_welcome_email()   # 2. email (what if it fails?)
#     await create_stripe_customer() # 3. billing (another failure point)
#     await notify_slack()          # 4. notify (yet another)
#     # If step 3 fails, steps 1-2 already happened. Messy rollback.
#
# WITH Kafka (loosely coupled):
# @router.post("/signup")
# async def signup(user):
#     save_to_db(user)
#     await kafka.send("user_events", {"event": "user_signed_up", ...})
#     # Done! Each service picks it up independently:
#
#   ┌── email-service (group: "email") ───── sends welcome email
#   │
#   ├── billing-service (group: "billing") ── creates Stripe customer
#   │
#   └── notification-service (group: "notif") ── posts to Slack
#
# Each consumer group gets its OWN copy of every message.
# If email-service is down, messages wait. No data lost.
# Real example — producer publishes ONE event
@router.post("/signup")
async def signup(user: UserCreate, db = Depends(get_db)):
    db_user = User(**user.dict())
    db.add(db_user)
    db.commit()

    # Single event — multiple services react
    await kafka_producer.send(
        topic="user_events",
        key=str(db_user.id),
        value={
            "event": "user_signed_up",
            "user_id": db_user.id,
            "email": db_user.email,
            "name": db_user.name,
            "timestamp": datetime.utcnow().isoformat(),
        }
    )

    return {"id": db_user.id}

Consumer Groups & Scaling

Consumer groups let you scale horizontally — add more consumers and Kafka automatically distributes partitions across them.

# How consumer groups work:
#
# Topic "orders" has 4 partitions:
#   Partition 0 ──┐
#   Partition 1 ──┤
#   Partition 2 ──┤
#   Partition 3 ──┘
#
# Consumer Group "email-service" with 2 consumers:
#   Consumer A → reads Partition 0, 1
#   Consumer B → reads Partition 2, 3
#
# Scale up to 4 consumers → each gets exactly 1 partition
# Scale up to 5 consumers → 1 sits idle (more consumers than partitions = waste)
#
# Different groups are INDEPENDENT:
#   Group "email-service"   → gets ALL messages (for sending emails)
#   Group "analytics-service" → gets ALL messages (for tracking)
#   Each group maintains its own offset (bookmark)
# Multiple consumers in the same group — they share the load
# Run these as separate processes/containers:

# Instance 1
consumer_1 = AIOKafkaConsumer(
    "orders",
    group_id="email-service",   # same group
    bootstrap_servers="localhost:9092",
)

# Instance 2 (separate process)
consumer_2 = AIOKafkaConsumer(
    "orders",
    group_id="email-service",   # same group → Kafka splits partitions
    bootstrap_servers="localhost:9092",
)

# Different group → gets its OWN copy of every message
analytics_consumer = AIOKafkaConsumer(
    "orders",
    group_id="analytics-service",  # different group
    bootstrap_servers="localhost:9092",
)

Redis + Kafka Together

Redis and Kafka solve different problems — use them together for a complete architecture.

FeatureRedisKafka
PurposeCache, sessions, rate limitingEvent streaming, service communication
SpeedSub-millisecond readsHigh throughput, slight latency
Data lifetimeTemporary (TTL-based)Persistent (days/weeks/forever)
PatternKey-value lookupPublish-subscribe / event log
ScalingVertical (bigger instance)Horizontal (more partitions/brokers)
If it crashesCache is gone (rebuild from DB)Messages are safe (replicated)
# Common architecture: FastAPI + Redis + Kafka + PostgreSQL
#
#                    ┌─── Redis (cache) ───┐
#                    │                      │
# Client → FastAPI ──┼─── PostgreSQL (data) │
#                    │                      │
#                    └─── Kafka (events) ───┘
#                              │
#                    ┌─────────┼─────────┐
#                    │         │         │
#               email-svc  analytics  billing
#
# 1. Request comes in → check Redis cache
# 2. Cache miss → query PostgreSQL
# 3. Store result in Redis for next time
# 4. Publish event to Kafka for side effects
# 5. Other services consume events independently
Rule of thumb: PostgreSQL is your source of truth. Redis makes reads fast. Kafka connects your services. Use all three together for production-grade APIs.

23 Interview Questions

What is itThis section is a curated collection of FastAPI-specific interview questions you'll commonly see in backend engineer, platform engineer, and Python developer interviews at companies building APIs or ML services. They cover the framework's core concepts (routing, dependencies, Pydantic), the async model (ASGI vs WSGI, event loop, blocking I/O), and production concerns (deployment, security, performance). Expect both conceptual questions ("what is dependency injection and why does FastAPI have it") and practical ones ("here's a slow endpoint, diagnose it").
Topic categories
  • Framework fundamentals: ASGI vs WSGI, Starlette vs FastAPI, Pydantic's role.
  • Type system: How type hints drive validation, Annotated, Field, Query, Path, Body.
  • Async & concurrency: Event loop, async def vs def, blocking I/O, threadpool offload.
  • Dependency injection: Caching, sub-dependencies, yield teardown, overrides.
  • Data modeling: Pydantic v1 vs v2, separate input/output models, ORMs, from_attributes.
  • Security: OAuth2, JWT, CORS, CSRF, dependency-injected auth.
  • Performance: Benchmarks, profiling, connection pools, caching.
  • Production ops: Gunicorn workers, health checks, graceful shutdown, logging.
How to prepare
  • Build a non-trivial app: CRUD + auth + background tasks + WebSocket + tests. Nothing beats real experience.
  • Read the official docs cover-to-cover: They're unusually high quality — ~2-3 hours.
  • Understand the stack: Know what happens from TCP accept → ASGI call → Pydantic validation → your handler → response serialization.
  • Practice system design: "Design a URL shortener with FastAPI" — be ready to discuss DB choice, caching, rate limits.
  • Read Pydantic source: Especially how validators work — it's a favorite deep-dive question.
Red flags interviewers look for
  • Using requests inside async def routes.
  • Not knowing the difference between Query(), Path(), Body(), and Depends().
  • Mixing Pydantic v1 and v2 syntax.
  • Claiming FastAPI is "just Flask with types."
  • Not understanding why response_model exists (sensitive field leakage).
  • Using a single global DB session instead of per-request.
Green flags
  • Explaining why FastAPI is fast (ASGI + uvloop + Pydantic Rust core) rather than just saying "it's fast."
  • Knowing when to use BackgroundTasks vs a real queue like Celery/Arq.
  • Understanding the request lifecycle and where middleware, dependencies, and exception handlers sit.
  • Being comfortable with both sync and async patterns and knowing when each applies.
  • Mentioning observability (Sentry, OpenTelemetry, structured logging) without prompting.
Real-world examplesCompanies actively hiring FastAPI engineers include Netflix, Microsoft, Uber, OpenAI, Anthropic, Stripe, Hugging Face, Explosion AI, DataRobot, Replit, and countless ML-focused startups. The combination of Python + ML + FastAPI is one of the hottest backend stacks of 2024–2026.
Expand All

Fundamentals

What is FastAPI and how is it different from Flask/Django?

FastAPI is built on Starlette (ASGI) + Pydantic (validation). Flask/Django are WSGI-based.

FeatureFastAPIFlaskDjango
AsyncNativeLimited (2.0+)Limited (3.1+)
ValidationAuto (Pydantic)ManualForms/DRF serializers
API DocsAuto Swagger + ReDocManualDRF + drf-spectacular
SpeedNode.js/Go levelModerateModerate
Type hintsCore featureOptionalOptional

FastAPI = API-first. Django = full-stack (admin, ORM, templates). Flask = micro-framework (BYO everything).

What is ASGI vs WSGI? Why does it matter?

WSGI (Web Server Gateway Interface) — synchronous. One request per thread. Flask/Django traditionally use this.

ASGI (Asynchronous Server Gateway Interface) — async-native. Handles thousands of concurrent connections on a single thread via event loop.

# WSGI — blocks during I/O
def app(environ, start_response):
    data = fetch_from_db()  # thread blocked here
    start_response("200 OK", headers)
    return [data]

# ASGI — non-blocking
async def app(scope, receive, send):
    data = await fetch_from_db()  # yields control
    await send(response)

FastAPI uses ASGI → can handle WebSockets, long-polling, SSE, and high-concurrency I/O without threads.

What is Pydantic and why does FastAPI use it?

Pydantic is a data validation library using Python type hints. FastAPI uses it for request/response validation, serialization, and auto-documentation.

from pydantic import BaseModel, Field, field_validator

class User(BaseModel):
    name: str = Field(min_length=1, max_length=50)
    email: str
    age: int = Field(ge=0, le=150)

    @field_validator("email")
    @classmethod
    def validate_email(cls, v):
        if "@" not in v:
            raise ValueError("Invalid email")
        return v

# FastAPI auto-validates request body:
@app.post("/users")
async def create(user: User):  # ← auto-validated
    return user

Invalid data → automatic 422 Unprocessable Entity with detailed error messages. Zero manual validation code.

What are path parameters vs query parameters? When to use which?
# Path parameter — identifies a SPECIFIC resource
@app.get("/users/{user_id}")
async def get_user(user_id: int):  # /users/42
    ...

# Query parameters — filter/modify the response
@app.get("/users")
async def list_users(
    skip: int = 0,
    limit: int = 10,
    role: str | None = None
):  # /users?skip=0&limit=10&role=admin
    ...

Rule: Path params = required, identifies resource (/users/42). Query params = optional, filters/pagination (?page=2&sort=name).

Dependency Injection & Middleware

Explain FastAPI's Dependency Injection system. Why is it powerful?

DI in FastAPI lets you declare shared logic (DB sessions, auth, pagination) that gets automatically resolved and injected into route handlers.

from fastapi import Depends

# Dependency — reusable across routes
async def get_db():
    db = SessionLocal()
    try:
        yield db       # injected into route
    finally:
        db.close()   # cleanup runs after response

# Sub-dependency — dependencies can depend on other dependencies
async def get_current_user(
    token: str = Depends(oauth2_scheme),
    db: Session = Depends(get_db)
):
    user = db.query(User).filter_by(token=token).first()
    if not user: raise HTTPException(401)
    return user

# Route just declares what it needs
@app.get("/profile")
async def profile(user: User = Depends(get_current_user)):
    return user

Why powerful: Testable (override deps in tests), composable (deps chain), auto-cleanup (yield), cached per request (same dep instance reused).

What's the difference between Depends with yield vs return? When does cleanup run?
# Return — simple, no cleanup needed
def get_settings():
    return Settings()  # just returns a value

# Yield — has setup + teardown (like context manager)
async def get_db():
    db = SessionLocal()      # SETUP: before yield
    try:
        yield db             # INJECT: this value goes to the route
    finally:
        db.close()           # TEARDOWN: runs after response is sent

Execution order:

  1. Code before yield runs → setup
  2. yield value injected into route handler
  3. Route handler executes and returns response
  4. Code after yield runs → teardown (even if route raised an exception)

Tricky gotcha: Exceptions in the route handler propagate into the yield dependency. That's why you need try/finally — without it, cleanup won't run on error.

Middleware vs Dependencies — when to use which?
FeatureMiddlewareDependency
ScopeEvery requestSpecific routes
AccessRaw request/responseParsed params
Use forCORS, logging, timingAuth, DB, validation
Can modify response?YesNo (only input)
Testable?HarderEasy (override)
# Middleware — runs on EVERY request
@app.middleware("http")
async def add_timing(request: Request, call_next):
    start = time.time()
    response = await call_next(request)
    response.headers["X-Process-Time"] = str(time.time() - start)
    return response

# Dependency — runs only on routes that declare it
@app.get("/admin", dependencies=[Depends(require_admin)])
async def admin_panel(): ...

Rule: Cross-cutting concerns (logging, CORS, headers) → middleware. Business logic (auth, DB, pagination) → dependencies.

How do you override dependencies in tests?
from fastapi.testclient import TestClient

# Production dependency
async def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

# Test override — use test database
async def override_get_db():
    db = TestingSessionLocal()
    try:
        yield db
    finally:
        db.rollback()
        db.close()

app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)

# Now all routes using Depends(get_db) will use test DB
response = client.get("/users")

# Clean up after tests
app.dependency_overrides.clear()

Key insight: dependency_overrides is a dict mapping original_dep → replacement_dep. This is why DI is superior to hard-coded function calls — zero code changes for testing.

Async, Performance & Tricky Behavior

async def vs def in FastAPI — what ACTUALLY happens?
# async def — runs on the event loop (main thread)
@app.get("/async")
async def async_route():
    data = await async_db_query()  # non-blocking
    return data

# def — FastAPI runs it in a THREAD POOL automatically!
@app.get("/sync")
def sync_route():
    data = blocking_db_query()  # runs in threadpool
    return data

The trap: If you use async def but call blocking code (without await), you block the entire event loop. No other request can be processed.

# ❌ WRONG — blocks event loop!
@app.get("/bad")
async def bad_route():
    time.sleep(5)  # blocks EVERYTHING for 5 seconds
    return {"msg": "done"}

# ✅ Option 1 — use regular def (runs in threadpool)
@app.get("/good1")
def good_route():
    time.sleep(5)  # runs in thread, event loop free
    return {"msg": "done"}

# ✅ Option 2 — use async sleep
@app.get("/good2")
async def good_route():
    await asyncio.sleep(5)  # non-blocking
    return {"msg": "done"}

# ✅ Option 3 — run blocking code in executor
@app.get("/good3")
async def good_route():
    data = await asyncio.to_thread(blocking_function)
    return data

Rule: async def → only use await-able calls. def → blocking is fine (auto-threadpooled). Mixing them wrong is the #1 FastAPI performance killer.

What is the lifespan event? How is it different from on_event?
# ❌ OLD WAY (deprecated in FastAPI 0.93+)
@app.on_event("startup")
async def startup():
    app.state.db = create_pool()

@app.on_event("shutdown")
async def shutdown():
    await app.state.db.close()

# ✅ NEW WAY — lifespan context manager
from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app: FastAPI):
    # STARTUP — runs before first request
    app.state.db = await create_pool()
    app.state.redis = await aioredis.from_url("redis://localhost")

    yield  # app runs here

    # SHUTDOWN — runs after last response
    await app.state.db.close()
    await app.state.redis.close()

app = FastAPI(lifespan=lifespan)

Why lifespan? Startup and shutdown are coupled — resources created in startup must be cleaned in shutdown. The context manager pattern guarantees this. on_event doesn't.

How does BackgroundTasks work? What are its limitations?
from fastapi import BackgroundTasks

@app.post("/send-email")
async def send_email(
    email: str,
    background: BackgroundTasks
):
    background.add_task(send_email_task, email)
    return {"msg": "Email queued"}  # returns immediately

def send_email_task(email: str):
    # runs AFTER response is sent
    smtp.send(email)  # blocking is OK — runs in threadpool

Limitations:

  • No retry — if it fails, it's gone. No error notification.
  • No persistence — server restart = lost tasks.
  • Same process — heavy tasks block your worker.
  • No tracking — can't check task status.

When to upgrade: Need retries/persistence → Celery or ARQ. Need event streaming → Kafka. BackgroundTasks is only for fire-and-forget lightweight work.

What happens if you await a non-coroutine? Or forget to await?
# ❌ Forgot to await — returns coroutine object, not result!
@app.get("/broken")
async def broken():
    result = async_db_query()  # missing await!
    return result
    # Returns: <coroutine object async_db_query at 0x...>
    # Plus a RuntimeWarning: coroutine was never awaited

# ❌ Awaiting a regular function
async def handler():
    result = await regular_function()  # TypeError!
    # TypeError: object int can't be used in 'await' expression

# ✅ Run sync function in async context
async def handler():
    result = await asyncio.to_thread(regular_function)

Debug tip: If your API returns weird objects or you see coroutine never awaited warnings — you forgot an await.

Pydantic Tricks & Gotchas

How do you create different Pydantic models for Create, Read, Update?
# Base — shared fields
class UserBase(BaseModel):
    name: str
    email: str

# Create — what client sends (no id)
class UserCreate(UserBase):
    password: str

# Read — what API returns (has id, no password)
class UserRead(UserBase):
    id: int
    model_config = ConfigDict(from_attributes=True)

# Update — all fields optional
class UserUpdate(BaseModel):
    name: str | None = None
    email: str | None = None

@app.post("/users", response_model=UserRead)
async def create(user: UserCreate): ...

@app.patch("/users/{id}", response_model=UserRead)
async def update(id: int, user: UserUpdate): ...

Why separate models? Security — UserCreate accepts password, UserRead never exposes it. UserUpdate makes everything optional for PATCH.

What is model_config = ConfigDict(from_attributes=True) and why do you need it?
# SQLAlchemy model — uses attributes (user.name)
class UserDB(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True)
    name = Column(String)

# Without from_attributes — FAILS
class UserRead(BaseModel):
    id: int
    name: str

user_db = db.query(UserDB).first()
UserRead.model_validate(user_db)  # ❌ Pydantic expects dict

# With from_attributes — WORKS
class UserRead(BaseModel):
    id: int
    name: str
    model_config = ConfigDict(from_attributes=True)

UserRead.model_validate(user_db)  # ✅ reads user_db.id, user_db.name

Old name: class Config: orm_mode = True (Pydantic v1). New name: from_attributes=True (Pydantic v2).

Tells Pydantic to read data from object attributes (obj.field), not just dict keys (obj["field"]).

What's the difference between Field, Query, Path, Body, Header?
from fastapi import Query, Path, Body, Header

@app.get("/items/{item_id}")
async def read_item(
    # Path — from URL path
    item_id: int = Path(ge=1, description="Item ID"),

    # Query — from ?key=value
    q: str = Query(max_length=50, default=None),

    # Header — from request headers
    x_token: str = Header(),
):
    ...

@app.post("/items")
async def create(
    # Body — from JSON body (can embed multiple)
    item: Item = Body(embed=True),
):
    ...

Field is for inside Pydantic models. Query/Path/Body/Header are for route function parameters. They all support the same validation options (ge, max_length, regex, etc.).

How do you handle partial updates (PATCH) properly?
class ItemUpdate(BaseModel):
    name: str | None = None
    price: float | None = None
    description: str | None = None

@app.patch("/items/{item_id}")
async def update_item(item_id: int, updates: ItemUpdate):
    # exclude_unset=True is the KEY — only gets fields client sent
    update_data = updates.model_dump(exclude_unset=True)

    # Without exclude_unset:
    # {"name": None, "price": None, "description": None}
    # With exclude_unset (client sent {"name": "New"}):
    # {"name": "New"}  ← only what was actually sent

    db_item = get_item(item_id)
    for field, value in update_data.items():
        setattr(db_item, field, value)
    db.commit()

Gotcha: Without exclude_unset=True, you'll overwrite every field with None — even ones the client didn't send. This is the #1 PATCH bug.

Authentication & Security

Implement JWT auth flow in FastAPI from scratch
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import jwt
from datetime import datetime, timedelta

SECRET = "your-secret-key"
ALGO = "HS256"
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")

# 1. Create token
def create_token(data: dict, expires: timedelta = timedelta(hours=1)):
    payload = {**data, "exp": datetime.utcnow() + expires}
    return jwt.encode(payload, SECRET, algorithm=ALGO)

# 2. Verify token (dependency)
async def get_current_user(token: str = Depends(oauth2_scheme)):
    try:
        payload = jwt.decode(token, SECRET, algorithms=[ALGO])
        user_id = payload.get("sub")
        if not user_id: raise HTTPException(401)
    except jwt.JWTError:
        raise HTTPException(401, "Invalid token")
    return get_user_from_db(user_id)

# 3. Login route
@app.post("/login")
async def login(form: OAuth2PasswordRequestForm = Depends()):
    user = authenticate(form.username, form.password)
    if not user: raise HTTPException(401)
    token = create_token({"sub": str(user.id)})
    return {"access_token": token, "token_type": "bearer"}

# 4. Protected route
@app.get("/me")
async def me(user = Depends(get_current_user)):
    return user
How do you implement role-based access control (RBAC)?
from enum import Enum

class Role(str, Enum):
    USER = "user"
    ADMIN = "admin"
    MODERATOR = "moderator"

# Dependency factory — returns a dependency!
def require_role(*roles: Role):
    async def checker(user = Depends(get_current_user)):
        if user.role not in roles:
            raise HTTPException(403, "Forbidden")
        return user
    return checker

# Usage — clean and declarative
@app.delete("/users/{id}")
async def delete_user(
    id: int,
    admin = Depends(require_role(Role.ADMIN))
):
    ...

@app.put("/posts/{id}")
async def edit_post(
    id: int,
    user = Depends(require_role(Role.ADMIN, Role.MODERATOR))
):
    ...

Key pattern: require_role is a dependency factory — a function that returns a dependency. This lets you parameterize access control per route.

Database & ORM

How do you handle database sessions properly in FastAPI?
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session

engine = create_engine("postgresql://user:pass@localhost/db")
SessionLocal = sessionmaker(bind=engine)

# ✅ Correct — yield dependency with cleanup
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()  # ALWAYS close, even on error

@app.get("/users")
def list_users(db: Session = Depends(get_db)):
    return db.query(User).all()

# ❌ WRONG — session leaks on exceptions!
@app.get("/bad")
def bad_route():
    db = SessionLocal()
    users = db.query(User).all()  # if this throws...
    db.close()                     # this never runs!
    return users

Common gotcha: FastAPI's Depends ensures one session per request. If you create sessions manually inside routes, you risk leaks and inconsistent transactions.

SQLAlchemy sync vs async — which to use with FastAPI?
# Sync — simpler, use with `def` routes
from sqlalchemy import create_engine
engine = create_engine("postgresql://...")

@app.get("/users")
def list_users(db: Session = Depends(get_db)):
    return db.query(User).all()  # FastAPI auto-threadpools this

# Async — more complex, higher throughput
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession

engine = create_async_engine("postgresql+asyncpg://...")
AsyncSessionLocal = async_sessionmaker(engine)

async def get_db():
    async with AsyncSessionLocal() as session:
        yield session

@app.get("/users")
async def list_users(db: AsyncSession = Depends(get_db)):
    result = await db.execute(select(User))
    return result.scalars().all()

Rule of thumb: Start with sync (simpler). Switch to async when you need high concurrency (1000+ concurrent connections). Sync with def routes is fast enough for most apps since FastAPI auto-threadpools them.

How do you handle database migrations with Alembic?
# Setup
# pip install alembic
# alembic init alembic

# alembic/env.py — point to your models
from app.models import Base
target_metadata = Base.metadata

# Generate migration after model changes
# alembic revision --autogenerate -m "add users table"

# Apply migration
# alembic upgrade head

# Rollback
# alembic downgrade -1

Interview trap: "What happens if you change a model but don't create a migration?" → Nothing changes in the database. SQLAlchemy models and database schema are independent. Alembic bridges them.

Production tip: Always review autogenerated migrations. They can miss renames (sees drop + create instead) and data migrations.

Architecture & Design

How do you structure a large FastAPI project? Repository pattern?
# 3-layer architecture:
# Router → Service → Repository

# router — HTTP concerns only
@router.get("/users/{id}", response_model=UserRead)
async def get_user(id: int, service: UserService = Depends()):
    return service.get_by_id(id)

# service — business logic
class UserService:
    def __init__(self, repo: UserRepo = Depends()):
        self.repo = repo

    def get_by_id(self, id: int):
        user = self.repo.find(id)
        if not user:
            raise HTTPException(404)
        return user

# repository — database access only
class UserRepo:
    def __init__(self, db: Session = Depends(get_db)):
        self.db = db

    def find(self, id: int):
        return self.db.query(User).get(id)

Why? Router doesn't know about DB. Service doesn't know about HTTP. Repository doesn't know about business rules. Each layer is independently testable.

APIRouter vs including routers — how does it work?
# app/routers/users.py
from fastapi import APIRouter

router = APIRouter(
    prefix="/users",
    tags=["users"],
    dependencies=[Depends(get_current_user)]  # applies to ALL routes
)

@router.get("/")
async def list_users(): ...   # GET /users/

@router.get("/{id}")
async def get_user(id: int): ...   # GET /users/42

# app/main.py
from app.routers import users, products, orders

app = FastAPI()
app.include_router(users.router)
app.include_router(products.router)
app.include_router(orders.router, prefix="/v2")  # → /v2/orders/

Key: Routers have their own prefix, tags, and dependencies. include_router can add additional prefix (useful for API versioning).

How do you handle custom exception handlers?
# Custom exception class
class NotFoundError(Exception):
    def __init__(self, resource: str, id: int):
        self.resource = resource
        self.id = id

# Register handler — catches this exception ANYWHERE
@app.exception_handler(NotFoundError)
async def not_found_handler(request: Request, exc: NotFoundError):
    return JSONResponse(
        status_code=404,
        content={
            "error": f"{exc.resource} {exc.id} not found",
            "type": "not_found"
        }
    )

# Now just raise anywhere — no try/except in routes
class UserService:
    def get(self, id: int):
        user = self.repo.find(id)
        if not user:
            raise NotFoundError("User", id)  # caught globally
        return user

Gotcha: Exception handlers don't catch HTTPException by default — FastAPI has its own handler for that. To override it, register a handler for HTTPException explicitly.

How do you version your API?
# Option 1 — URL prefix (most common)
app.include_router(users_v1.router, prefix="/v1")
app.include_router(users_v2.router, prefix="/v2")
# GET /v1/users, GET /v2/users

# Option 2 — Header-based
@app.get("/users")
async def get_users(accept_version: str = Header(default="1")):
    if accept_version == "2":
        return v2_response()
    return v1_response()

# Option 3 — Separate FastAPI apps (for major versions)
app_v1 = FastAPI()
app_v2 = FastAPI()

main_app = FastAPI()
main_app.mount("/v1", app_v1)
main_app.mount("/v2", app_v2)

Best practice: URL prefix for simplicity. mount() for completely different API versions with separate docs pages.

Tricky & Brain Teasers

Why does this route return {"item_id": "me"} instead of the current user?
@app.get("/users/{user_id}")
async def get_user(user_id: str):
    return {"user_id": user_id}

@app.get("/users/me")
async def get_me():
    return {"user": "current"}

# GET /users/me → {"user_id": "me"} — WRONG!

Why? Route order matters! /users/{user_id} matches /users/me first because it was defined first. FastAPI evaluates routes top to bottom.

Fix — put specific routes BEFORE parameterized ones:

@app.get("/users/me")       # ← FIRST
async def get_me(): ...

@app.get("/users/{user_id}")  # ← SECOND
async def get_user(user_id: str): ...
What's wrong here? — The response_model leak trap
class UserDB(BaseModel):
    id: int
    name: str
    email: str
    hashed_password: str  # SECRET!

# ❌ SECURITY BUG — leaks password hash!
@app.get("/users/{id}")
async def get_user(id: int):
    user = db.query(User).get(id)
    return user  # returns ALL fields including hashed_password

# ✅ FIX — use response_model to filter output
class UserRead(BaseModel):
    id: int
    name: str
    email: str
    # no password field!

@app.get("/users/{id}", response_model=UserRead)
async def get_user(id: int):
    return db.query(User).get(id)  # password stripped by response_model

Lesson: response_model isn't just for docs — it's a security filter. Without it, you can accidentally expose internal fields (passwords, tokens, internal IDs).

Why does this dependency run twice? — Caching gotcha
async def get_db():
    print("Creating DB session")  # How many times?
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

async def get_user(db = Depends(get_db)):
    return db.query(User).first()

async def get_settings(db = Depends(get_db)):
    return db.query(Settings).first()

@app.get("/")
async def home(
    user = Depends(get_user),
    settings = Depends(get_settings)
):
    return {"user": user, "settings": settings}

# "Creating DB session" prints ONCE, not twice!

Why? FastAPI caches dependencies per request by default. Same dependency function → same instance within one request. This is why get_db runs once even though two sub-dependencies use it.

To disable caching (rare — e.g., you want a fresh random value):

async def get_random():
    return random.randint(1, 100)

# use_cache=False → runs fresh each time
@app.get("/")
async def route(
    a = Depends(get_random, use_cache=False),
    b = Depends(get_random, use_cache=False)
):
    return {"a": a, "b": b}  # different values!
What's the output? — The mutable Pydantic default trap
# ❌ This looks fine but is WRONG in Pydantic v1
class Item(BaseModel):
    tags: list[str] = []  # shared mutable default!

# Pydantic v2 actually handles this correctly — it copies the default
# But in v1, this was a real trap (same as Python's mutable default arg)

# ✅ Safe way (works in both v1 and v2)
class Item(BaseModel):
    tags: list[str] = Field(default_factory=list)

# The REAL Pydantic trap — model_validate vs __init__
class User(BaseModel):
    name: str
    age: int

# These are NOT the same!
u1 = User(name="Alice", age="25")    # Works! Coerces "25" → 25
u2 = User(name="Alice", age="abc")   # ValidationError!

# Strict mode — no coercion
class StrictUser(BaseModel):
    model_config = ConfigDict(strict=True)
    name: str
    age: int

StrictUser(name="Alice", age="25")  # ❌ ValidationError! str != int

Key insight: Pydantic coerces types by default"25" becomes 25. This is usually helpful but can hide bugs. Use strict=True when you need exact types.

What happens when two routes have the same path and method?
@app.get("/items")
async def items_v1():
    return ["v1"]

@app.get("/items")
async def items_v2():
    return ["v2"]

# GET /items → ["v1"] — first one wins, second is SILENTLY ignored!
# No error, no warning. 😱

Why it's dangerous: When using include_router with multiple routers, you can accidentally create duplicate routes — especially with "/" paths. FastAPI won't warn you.

Debug tip: Check /docs (Swagger UI) — duplicate routes will show up there. Or inspect app.routes programmatically.

Implement a rate limiter using dependencies
from collections import defaultdict
from time import time

# In-memory rate limiter (use Redis in production)
requests_log: dict[str, list[float]] = defaultdict(list)

def rate_limit(max_requests: int = 10, window: int = 60):
    async def checker(request: Request):
        client_ip = request.client.host
        now = time()

        # Clean old entries
        requests_log[client_ip] = [
            t for t in requests_log[client_ip]
            if now - t < window
        ]

        if len(requests_log[client_ip]) >= max_requests:
            raise HTTPException(
                429,
                detail="Too many requests",
                headers={"Retry-After": str(window)}
            )
        requests_log[client_ip].append(now)
    return checker

# Usage — 5 requests per 60 seconds
@app.get("/api/data", dependencies=[Depends(rate_limit(5, 60))])
async def get_data():
    return {"data": "here"}

Why this is tricky: rate_limit(5, 60) is a dependency factory — it returns a function that FastAPI calls. The outer function configures, the inner function executes per request.

Production: Use Redis instead of in-memory dict — this won't work with multiple workers.

What's wrong with this WebSocket implementation?
# ❌ BUG — doesn't handle disconnection!
@app.websocket("/ws")
async def websocket(ws: WebSocket):
    await ws.accept()
    while True:
        data = await ws.receive_text()  # crashes on disconnect!
        await ws.send_text(f"Echo: {data}")

# ✅ FIX — handle WebSocketDisconnect
from fastapi import WebSocketDisconnect

@app.websocket("/ws")
async def websocket(ws: WebSocket):
    await ws.accept()
    try:
        while True:
            data = await ws.receive_text()
            await ws.send_text(f"Echo: {data}")
    except WebSocketDisconnect:
        print("Client disconnected")  # clean up here

Another gotcha: WebSocket routes don't use response_model, don't return HTTP status codes, and middleware runs differently for them. Auth must be handled manually (usually via query params since headers aren't sent in the initial handshake from browsers).

How does FastAPI auto-generate OpenAPI docs? Can you customize them?
# FastAPI reads your type hints, models, and decorators
# to auto-generate the OpenAPI schema at /openapi.json

# Customize per-route:
@app.get(
    "/users",
    summary="List all users",
    description="Returns paginated user list",
    response_description="List of users",
    tags=["users"],
    deprecated=False,
    responses={
        404: {"description": "Not found"},
        500: {"description": "Server error"},
    }
)
async def list_users(): ...

# Customize globally:
app = FastAPI(
    title="My API",
    version="2.0.0",
    description="Production API",
    docs_url="/swagger",        # custom path (default: /docs)
    redoc_url="/documentation",  # custom path (default: /redoc)
    openapi_url="/api/schema",   # custom schema path
)

# Disable docs in production:
app = FastAPI(docs_url=None, redoc_url=None)

Key insight: Everything in Swagger comes from your code — type hints, Pydantic models, decorator args. Better type annotations = better docs. Zero extra effort.


Previous Guide Python — The Complete Guide Variables, OOP, data structures, decorators, NumPy, Pandas — the foundation.