Skip to content

FastAPI REST APIs

You’ve spent four modules building the parts: type hints (02), Pydantic models (04), and asyncio (05). FastAPI is where they snap together into a web framework. It is the default modern Python backend framework in 2026 — async-first, validated by Pydantic, and it generates an OpenAPI schema and Swagger UI for free.

If you write Express or Nest in TypeScript, or net/http/Gin/Echo in Go, the HTTP concepts transfer directly. What’s new is how little glue you write: the type hint on a parameter is the parser, the validator, and the OpenAPI schema, all at once. A function signature like def create(body: TaskCreate) -> Task tells FastAPI everything it needs to deserialize the body, reject bad input with a 422, serialize the response, and document both.

ConceptExpress / Nest (TS)Go (Gin / net-http)FastAPI (Python)
FrameworkExpress / NestJSGin / Echo / net/httpFastAPI
Runtime modelevent loop (single thread)goroutinesasyncio event loop (ASGI)
ServerNode runtimeGo runtimeuvicorn (ASGI server)
Routeapp.get("/p", h)r.GET("/p", h)@app.get("/p")
Router moduleexpress.Router() / Nest moduler.Group("/api")APIRouter()
Path paramreq.params.idc.Param("id")def f(id: int)
Query paramreq.query.qc.Query("q")def f(q: str | None = None)
Request bodyreq.body (+ Zod)c.BindJSON(&s)def f(body: Model)
ValidationZod / class-validatorvalidator struct tagsPydantic (built in)
DINest providers / manualmanual wiring / wireDepends(...)
Lifecycle hooksonModuleInit / manualdefer in mainlifespan context manager
Configdotenv / ConfigModuleenvconfig / Viperpydantic-settings
OpenAPI@nestjs/swagger (opt-in)swaggo (opt-in)automatic
  • ASGI + async-first. Routes are async def. The whole stack — server (uvicorn), framework, DB drivers (asyncpg), HTTP client (httpx) — speaks asyncio. One process handles thousands of concurrent connections on a single thread, the same shape as Node’s event loop. (Sync def routes are also supported — FastAPI runs them in a threadpool so they don’t block the loop.)
  • Pydantic-powered validation. Request bodies, query params, and responses are validated and coerced by the Pydantic v2 models from module 04. Bad input gets a structured 422 automatically — no hand-written guards.
  • Automatic OpenAPI. Your type hints generate a live OpenAPI 3.1 schema, served as Swagger UI at /docs and ReDoc at /redoc. This is the headline feature; Nest and Go need extra annotations to get there.
  • Dependency injection without a container. Depends(...) wires shared logic (DB sessions, auth, settings) into routes by function signature — no decorators-on-classes, no DI container to register, no codegen.

A FastAPI project is just a uv project (see module 01). No scaffolding tool, no generator.

Terminal window
mkdir task-api && cd task-api
npm init -y
npm install express
npm install -D typescript @types/express tsx
npx tsx watch src/index.ts

uv add fastapi uvicorn pulls in FastAPI and the ASGI server. fastapi dev (shipped with the fastapi package) is the modern dev runner — auto-reload, and it prints the /docs URL on startup. In production you run uvicorn directly:

Terminal window
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 # production
uv run fastapi dev app/main.py # development (reload)

A path operation is a function decorated with the HTTP method. Return a dict, a Pydantic model, or any JSON-serializable value and FastAPI serializes it — no res.json() call.

app/main.py
from fastapi import FastAPI
app = FastAPI(title="Task API", version="1.0.0")
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}

The same hello-world in Express and Gin:

import express from "express";
const app = express();
app.use(express.json());
app.get("/health", (req, res) => {
res.json({ status: "ok" });
});
app.listen(8000);

Declare them as function parameters with type hints. A parameter named in the path ({task_id}) is a path param; anything else is a query param. FastAPI parses, coerces, and validates from the URL — task_id: int rejects /tasks/abc with a 422 before your code runs.

from fastapi import FastAPI
app = FastAPI()
# Path param: /tasks/42 -> task_id == 42 (int, coerced & validated)
@app.get("/tasks/{task_id}")
async def get_task(task_id: int) -> dict[str, int]:
return {"id": task_id}
# Query params: /tasks?limit=10&done=true
# done is optional (has a default); limit is coerced to int.
@app.get("/tasks")
async def list_tasks(limit: int = 20, done: bool | None = None) -> dict:
return {"limit": limit, "done": done}
ExpressGinFastAPI
Pathreq.params.id (string)c.Param("id") (string)id: int (typed + coerced)
Queryreq.query.q (string|undefined)c.Query("q") (string)q: str | None = None
Required?manual checkmanual checkno default ⇒ required, auto-422
Coercionmanual Number(...)manual strconv.Atoifrom the type hint

The Python win here: there’s no parseInt, no strconv.Atoi, no “is this query string present?” guard. The type hint does coercion and validation, and a bad value produces a clean 422 with a JSON body pointing at the offending field.

This is where module 04 pays off. Type a parameter as a Pydantic model and FastAPI reads the JSON body, validates it against the model, and hands you a fully-typed instance. Validation failures become a 422 automatically.

import { z } from "zod";
const TaskCreate = z.object({
title: z.string().min(1).max(200),
description: z.string().default(""),
});
app.post("/tasks", (req, res) => {
const parsed = TaskCreate.safeParse(req.body); // explicit
if (!parsed.success) return res.status(422).json(parsed.error);
const body = parsed.data;
res.status(201).json({ ...body });
});

Notice the asymmetry: in TS and Go you call the validator and branch on the result. In FastAPI the validation is declarative — it happens before your function body, and a failure never reaches your code. The 422 response shape is consistent across the whole app (more on that under Error handling).

A return type hint of a Pydantic model does double duty: it documents the response in OpenAPI and filters the output to exactly that model’s fields. You can also pass response_model= explicitly when the return type differs from the serialized shape (e.g. stripping a password hash from a DB row).

from pydantic import BaseModel
from fastapi import FastAPI, status
app = FastAPI()
class Task(BaseModel):
id: int
title: str
class TaskWithSecret(BaseModel):
id: int
title: str
internal_note: str
# response_model filters output to Task — internal_note is dropped from the JSON.
@app.get("/tasks/{task_id}", response_model=Task)
async def get_task(task_id: int) -> TaskWithSecret:
return TaskWithSecret(id=task_id, title="demo", internal_note="hidden")
# status_code sets the default success code; 201 for creates.
@app.post("/tasks", status_code=status.HTTP_201_CREATED)
async def create_task(task: Task) -> Task:
return task

A flat app with every route on it doesn’t scale. APIRouter is FastAPI’s express.Router() / Gin route group / Nest module: a sub-collection of routes with a shared prefix and tags, mounted onto the app with include_router.

app/routes.py
from fastapi import APIRouter
router = APIRouter(prefix="/tasks", tags=["tasks"])
@router.get("")
async def list_tasks() -> list[dict]:
return []
@router.get("/{task_id}")
async def get_task(task_id: int) -> dict:
return {"id": task_id}
app/main.py
from fastapi import FastAPI
from app.routes import router as tasks_router
app = FastAPI()
app.include_router(tasks_router) # mounts /tasks/*

tags=["tasks"] groups these endpoints under a “tasks” heading in the Swagger UI. The prefix is prepended to every route in the router.

Depends(...) is FastAPI’s most distinctive feature, and the one that least resembles Express or Go. A dependency is a callable (usually a function) whose return value is injected into a route. FastAPI runs it before the handler, caches it per-request, and — because dependencies can themselves have dependencies — resolves a whole graph for you.

The modern style uses Annotated[T, Depends(fn)], which keeps the type hint (T) and the injection (Depends(fn)) on the same parameter so it reads as a normal typed argument.

// Nest: providers registered in a module, injected by the framework
@Injectable()
class TaskService { /* ... */ }
@Controller("tasks")
class TaskController {
constructor(private readonly service: TaskService) {} // injected
}

The big difference from Nest is that there’s no container and no registration. A dependency is just a function; you wire it by referencing it in Depends(...). The big difference from Go is that the wiring is declarative per route rather than captured in closures at startup — which is what makes the testing override below possible.

Dependencies can depend on other dependencies, and FastAPI resolves the graph, caching each one per request. Here a route needs the “current user,” which needs a DB session, which needs settings:

from typing import Annotated
from fastapi import Depends, Header, HTTPException
def get_settings() -> Settings:
return Settings()
def get_repo(settings: Annotated[Settings, Depends(get_settings)]) -> TaskRepository:
return TaskRepository(settings.database_url)
def get_current_user(
repo: Annotated[TaskRepository, Depends(get_repo)],
x_user_id: Annotated[str | None, Header()] = None,
) -> User:
if x_user_id is None:
raise HTTPException(status_code=401, detail="missing X-User-Id")
return repo.find_user(x_user_id)
@router.get("/me")
async def me(user: Annotated[User, Depends(get_current_user)]) -> User:
return user # get_settings -> get_repo -> get_current_user, resolved for you

get_settings runs once per request even if three dependencies ask for it — FastAPI caches by callable within a request. This is the closest thing to Nest’s provider graph, but assembled on demand from plain functions.

A dependency that yields runs setup code, hands the yielded value to the route, then runs teardown after the response is sent. This is the canonical pattern for a per-request DB session — acquire on the way in, close on the way out, even if the handler raised.

from typing import Annotated
from fastapi import Depends
# Preview of the real thing in module 09 — for now, a hand-rolled session.
async def get_session():
session = await pool.acquire() # setup
try:
yield session # injected into the route
finally:
await pool.release(session) # teardown — always runs
@router.get("/tasks")
async def list_tasks(session: Annotated[Session, Depends(get_session)]):
return await session.fetch_all("SELECT * FROM tasks")
ExpressGoFastAPI
Per-request resourcemiddleware sets req.db, cleanup on res.finishdefer rows.Close() in handleryield dependency
Teardown on errorerror middlewaredeferfinally in the dependency

Because a route names its dependencies by callable, tests can swap any of them out via app.dependency_overrides — replace the real DB session with an in-memory fake without touching the route. This is the payoff for declarative DI, and it’s covered fully in module 12.

from app.main import app, get_session
async def fake_session():
yield InMemorySession()
app.dependency_overrides[get_session] = fake_session
# every route that Depends(get_session) now gets the fake; clear the dict to reset.

Most apps need to set up shared resources once at boot (a DB connection pool, a Redis client, an httpx.AsyncClient) and tear them down at shutdown. The modern API is a lifespan async context manager passed to FastAPI(...): code before yield runs on startup, code after runs on shutdown.

app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: open the pool, store it on app.state
app.state.pool = await create_pool("postgresql://dev:dev@localhost:5432/app")
yield
# Shutdown: close it cleanly
await app.state.pool.close()
app = FastAPI(lifespan=lifespan)
// Nest: onModuleInit / onApplicationShutdown lifecycle hooks
@Injectable()
class DbService implements OnModuleInit, OnApplicationShutdown {
async onModuleInit() { this.pool = await createPool(); }
async onApplicationShutdown() { await this.pool.end(); }
}

Config is a pydantic-settings BaseSettings model — the same Pydantic you already know, reading from environment variables and .env. Cover this fully in module 04; the FastAPI-specific move is to expose it as a cached dependency so routes can Depends(get_settings).

app/settings.py
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
database_url: str = "postgresql+asyncpg://dev:dev@localhost:5432/app"
max_tasks: int = 1000
@lru_cache
def get_settings() -> Settings:
return Settings() # built once, cached; inject with Depends(get_settings)

@lru_cache means the settings object is constructed once for the whole process (env vars don’t change at runtime), yet it’s still a dependency, so tests can override it. This replaces dotenv + a hand-rolled config object (TS) or envconfig/Viper (Go) — and it’s validated: a malformed MAX_TASKS=abc fails at startup, not at first use.

Raise HTTPException to short-circuit a request with a specific status and JSON body. This is the idiomatic “not found” / “forbidden” path.

from fastapi import HTTPException
@router.get("/tasks/{task_id}")
async def get_task(task_id: int, repo: RepoDep) -> Task:
task = repo.find(task_id)
if task is None:
raise HTTPException(status_code=404, detail="task not found")
return task
// Nest
if (!task) throw new NotFoundException("task not found");
// Express
if (!task) return res.status(404).json({ message: "task not found" });

For domain exceptions you don’t want HTTP-shaped at the throw site, register a handler that maps an exception type to a response — the equivalent of Nest’s exception filters or Express’s error middleware. Throw a plain domain error deep in your service; the handler turns it into JSON at the edge.

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
class TaskNotFound(Exception):
def __init__(self, task_id: int):
self.task_id = task_id
app = FastAPI()
@app.exception_handler(TaskNotFound)
async def task_not_found_handler(request: Request, exc: TaskNotFound) -> JSONResponse:
return JSONResponse(
status_code=404,
content={"error": "not_found", "task_id": exc.task_id},
)
# Now a service can `raise TaskNotFound(42)` and it becomes a clean 404.

When a request body or param fails Pydantic validation, FastAPI returns a 422 with a structured list of errors — every failure, with its location and message, not just the first. This consistency is free and is one of the reasons clients love FastAPI:

{
"detail": [
{
"type": "string_too_short",
"loc": ["body", "title"],
"msg": "String should have at least 1 character",
"input": ""
}
]
}

You can override the handler for RequestValidationError if you need a different shape, but the default is good enough for most APIs and matches the OpenAPI schema exactly.

Middleware wraps every request — the place for cross-cutting concerns like request logging or timing. The @app.middleware("http") decorator gives you the request and a call_next to invoke the rest of the chain.

import time
from fastapi import FastAPI, Request
app = FastAPI()
@app.middleware("http")
async def add_timing(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request) # ≈ next() / next.ServeHTTP
response.headers["X-Process-Time"] = f"{time.perf_counter() - start:.4f}"
return response

CORS is the one middleware almost every API needs. Use the built-in CORSMiddleware rather than hand-rolling headers:

from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "https://myapp.com"],
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["*"],
allow_credentials=True,
)
ExpressGo (Gin)FastAPI
Generic middlewareapp.use(fn)r.Use(fn)@app.middleware("http")
CORScors() packagecors.Default()CORSMiddleware
Per-routeroute-level middlewareroute group middlewarerouter-level dependencies=[...]

BackgroundTasks runs work after the response is sent — send the email, write the audit log — without making the client wait. Add it as a parameter and FastAPI injects it.

from fastapi import BackgroundTasks
def write_audit_log(task_id: int) -> None:
... # runs after the 201 is already on the wire
@router.post("/tasks", status_code=201)
async def create_task(body: TaskCreate, bg: BackgroundTasks) -> Task:
task = repo.create(body)
bg.add_task(write_audit_log, task.id) # deferred until response is sent
return task

For data that arrives over time — an LLM token stream, a CSV export, live progress — return a StreamingResponse wrapping an async generator. This ties directly to the async generators from module 06: the generator is the response body, yielded chunk by chunk.

import asyncio
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
async def event_stream():
for i in range(10):
yield f"data: tick {i}\n\n" # SSE frame
await asyncio.sleep(1)
@app.get("/events")
async def events() -> StreamingResponse:
return StreamingResponse(event_stream(), media_type="text/event-stream")

The client receives each frame as it’s produced; the connection stays open and the event loop is free to serve other requests in between awaits. That last clause is the whole async story — which leads to the one rule you must not break.

Async route best practices: don’t block the loop

Section titled “Async route best practices: don’t block the loop”

The single most common FastAPI performance bug, especially from devs arriving from thread-per-request stacks: calling blocking code inside an async def route. There’s one event loop. A blocking call (a sync DB driver, requests, time.sleep, a CPU-heavy loop) freezes every in-flight request until it returns.

import asyncio, time
import httpx
# WRONG — blocks the entire event loop for 2 seconds.
@app.get("/bad")
async def bad():
time.sleep(2) # synchronous sleep, blocks the loop
return {"ok": True}
# RIGHT — await an async operation; the loop serves others meanwhile.
@app.get("/good")
async def good():
await asyncio.sleep(2) # async sleep, yields control
async with httpx.AsyncClient() as client: # async HTTP, not `requests`
r = await client.get("https://example.com")
return {"status": r.status_code}

The selling point, restated because it’s that good: FastAPI generates an OpenAPI 3.1 schema from your type hints and serves two interactive UIs with zero config.

URLWhat it is
/docsSwagger UI — try requests in the browser
/redocReDoc — clean, readable reference
/openapi.jsonthe raw schema (feed it to client codegen)

Enrich the docs with metadata that costs almost nothing:

from fastapi import FastAPI, APIRouter
app = FastAPI(
title="Task API",
version="1.0.0",
description="A task management service.",
)
router = APIRouter(prefix="/tasks", tags=["tasks"])
@router.post(
"",
status_code=201,
summary="Create a task",
response_description="The created task",
)
async def create_task(body: TaskCreate) -> Task:
"""Create a task. The **docstring** becomes the endpoint's long description."""
...

tags group endpoints in the UI, summary is the short label, and the docstring becomes the expanded description. Compared to Nest (@nestjs/swagger decorators) or Go (swaggo comment annotations), you’ve already written all of this — it’s just your type hints and docstrings.

This module is the request/response skeleton. The flesh comes later:

  • Real databasemodule 09 replaces the in-memory repo with async SQLAlchemy + a yield session dependency.
  • Authmodule 13 adds JWT and OAuth2 using FastAPI’s security utilities (which are themselves Depends).
  • Testingmodule 12 tests routes with httpx’s ASGITransport and dependency overrides.
  • Litestarmodule 08 rebuilds the very same API in the other major async framework.

Build the complete Task CRUD API: Pydantic request/response models, an APIRouter, a Depends-injected repository, a lifespan, proper status codes, and the auto-generated OpenAPI docs. It’s the exact API module 08 rebuilds in Litestar, so keep the domain clean.