Back to Notes

FastAPI

Modern async Python web framework, built on Starlette (ASGI) + [[Pydantic]]. Auto-generates OpenAPI docs from type hints — the schema isn't written separately from the code, it's derived from it.

Minimal App

from fastapi import FastAPI
from pydantic import BaseModel

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

class User(BaseModel):
    name: str
    email: str

@app.get("/users/{user_id}")
async def get_user(user_id: int) -> User:
    return User(name="Alice", email="alice@example.com")

@app.post("/users", status_code=201)
async def create_user(user: User) -> User:
    return user   # `user` arrives already validated — Pydantic ran before this function body executes

/docs serves an interactive Swagger UI generated directly from these type hints — the documentation cannot drift from the actual accepted/returned shapes, since both are the same source of truth.

Path, Query, Body Parameters

from fastapi import Query, Path

@app.get("/items/{item_id}")
async def get_item(item_id: int = Path(gt=0)):           # path param, validated > 0
    ...

@app.get("/items")
async def list_items(skip: int = Query(0, ge=0), limit: int = Query(10, le=100)):
    ...                                                    # query params, cursor-style pagination — see API Design Principles

class CreateItem(BaseModel):
    name: str
    price: float

@app.post("/items")
async def create_item(item: CreateItem):                  # request body — parsed + validated as a Pydantic model
    ...

Response Models — Shaping What Goes Out, Not Just What Comes In

class UserResponse(BaseModel):
    id: int
    name: str
    # deliberately no password field

@app.get("/users/{user_id}", response_model=UserResponse)
async def get_user(user_id: int):
    user = await db.get_user(user_id)     # the DB object might have a password_hash field
    return user                            # FastAPI strips anything not in UserResponse automatically

Why this matters beyond convenience: response_model isn't just validation, it's an authorization boundary for output shape — even if the returned object happens to carry a sensitive field, the response model strips it before serialization. Forgetting to define a narrow response model (returning the raw DB object type instead) is a real, checkable way to accidentally leak fields like password hashes or internal IDs.

Dependency Injection — the Framework's Standout Feature

from fastapi import Depends

async def get_db() -> AsyncSession:
    async with SessionLocal() as session:
        yield session            # code after yield runs as cleanup, once the request finishes

async def get_current_user(token: str = Depends(oauth2_scheme), db: AsyncSession = Depends(get_db)) -> User:
    user = await verify_token(token, db)
    if not user:
        raise HTTPException(status_code=401, detail="Invalid token")
    return user

@app.get("/me")
async def get_me(current_user: User = Depends(get_current_user)):
    return current_user

Dependencies chain automaticallyget_current_user declares it needs get_db, and FastAPI resolves the whole chain without the route handler needing to know or construct any of it. This is the same dependency-inversion principle as [[SOLID Principles]]'s "D" — routes depend on an abstraction (Depends(...)), not a concrete session/user-lookup implementation, which is what makes swapping in a mock database session for tests trivial.

Middleware

from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
import time

app.add_middleware(CORSMiddleware, allow_origins=["https://myapp.com"], allow_methods=["*"])

class TimingMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        start = time.time()
        response = await call_next(request)
        response.headers["X-Process-Time"] = str(time.time() - start)
        return response

app.add_middleware(TimingMiddleware)

This is the same [[API Gateway]] cross-cutting-concerns pattern (auth, logging, timing applied uniformly across every route) implemented at the single-service level rather than at a fleet-wide gateway.

Background Tasks

from fastapi import BackgroundTasks

def send_email(email: str, message: str):
    email_client.send(email, message)   # runs AFTER the response is already sent to the client

@app.post("/register")
async def register(user: CreateUser, background_tasks: BackgroundTasks):
    created = await db.create_user(user)
    background_tasks.add_task(send_email, user.email, "Welcome!")
    return created   # client gets their response immediately; email send happens after, off the critical path

This is a lightweight, in-process version of the async-decoupling idea behind [[Message Queues & Kafka]] — fine for a best-effort side effect within a single process, but note it has none of a real queue's durability (a process crash after the response is sent, before the background task runs, silently drops it) — worth naming that trade-off if asked about it.

Routers — Modular Structure

# routers/users.py
router = APIRouter(prefix="/users", tags=["users"])

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

# main.py
app.include_router(users.router)

Error Handling

class AppError(Exception):
    def __init__(self, message: str, code: int = 400):
        self.message, self.code = message, code

@app.exception_handler(AppError)
async def app_error_handler(request, exc: AppError):
    return JSONResponse(status_code=exc.code, content={"error": exc.message})

A centralized exception handler is what keeps every route from needing its own repetitive try/except — matching [[API Design Principles]]'s consistent error-response-format requirement without duplicating the formatting logic per endpoint.

Lifespan Events

from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app: FastAPI):
    await db.connect()      # startup
    yield
    await db.disconnect()   # shutdown

app = FastAPI(lifespan=lifespan)

This is the exact same @contextmanager-generator pause/resume pattern discussed in [[Python Generators & Iterators]] — code before yield is setup, code after is guaranteed cleanup, applied here to the whole application's lifecycle rather than a single with block.

Interview Drills

  1. Why does defining a narrow response_model matter beyond input validation, when the input was never a concern for a GET request? — It also governs output shape — FastAPI strips any field on the returned object that isn't declared on the response model, which is a real security boundary (e.g., preventing an internal password_hash field from ever being serialized into the API response) even if the underlying DB object happens to carry it.
  2. How does FastAPI's dependency injection make testing easier? — Routes declare dependencies as Depends(some_function) rather than importing and calling a concrete implementation directly — in tests, app.dependency_overrides[get_db] = get_test_db swaps in a test double for the whole request without touching route code, the same dependency-inversion benefit named in [[SOLID Principles]].
  3. A background task added via BackgroundTasks needs to send a critical, must-not-be-lost notification. Is this the right tool? — No — BackgroundTasks runs in-process after the response is sent, with no durability guarantee; a process crash between "response sent" and "task executed" silently drops it. A must-not-be-lost side effect belongs on a real durable queue (see [[Message Queues & Kafka]]), not an in-process background task, which is better suited to best-effort side effects like a welcome email that's fine to occasionally miss.

Cross-links

[[Pydantic]] · [[Asyncio]] · [[API Design Principles]] · [[API Gateway]] · [[SOLID Principles]] · [[Message Queues & Kafka]]