Lifespan — Startup & Shutdown Done Properly
IntermediateThe lifespan context manager runs setup before the first request (DB pools, Redis, ML models) and teardown on exit — shared via app.state, probed by health endpoints, and replacing the deprecated on_event hooks.
Overview
Some resources belong to the application, not the request: connection pools, Redis clients, HTTP client sessions, a loaded ML model. The lifespan pattern — an @asynccontextmanager passed to FastAPI(lifespan=...) — runs everything before its yield once at startup and everything after on shutdown, replacing the deprecated @app.on_event("startup"/"shutdown") pair you will still meet in older codebases. Created resources hang on app.state, reachable from any request. Two production consequences follow. First, health checking splits in two: liveness ("process is up") stays trivial, readiness ("can I actually serve?") verifies the resources lifespan created — orchestrators route traffic only to ready instances. Second, remember lifespan is per worker process: four Uvicorn workers run it four times, so cross-worker coordination (migrations, cache warming) belongs in a release step, not in lifespan.
The Pattern — Setup, yield, Teardown
Everything heavy and shared initialises once, before traffic; everything closes cleanly on SIGTERM. The ML-model case shows why this exists: loading 2 GB of weights per request is absurd, loading at import breaks tooling — lifespan is the third way.
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI, Request
@asynccontextmanager
async def lifespan(app: FastAPI):
# ── STARTUP: before the first request is accepted ──
app.state.http = httpx.AsyncClient(timeout=10) # reused pool, all requests
app.state.model = load_recommender("model-v3.bin") # 2 GB of weights, ONCE
# engine/redis init also lives here in real apps
print("startup complete — accepting traffic")
yield # ← the app serves here
# ── SHUTDOWN: SIGTERM received, in-flight requests draining ──
await app.state.http.aclose() # return connections politely
print("shutdown clean")
app = FastAPI(lifespan=lifespan)
@app.get("/recommendations/{student_id}")
async def recommend(student_id: int, request: Request):
model = request.app.state.model # shared, already loaded
jobs = model.recommend(student_id, k=5)
# shared client → connection reuse instead of a new TCP+TLS handshake:
r = await request.app.state.http.get("https://api.internal/enrich")
return {"student": student_id, "jobs": jobs, "enriched": r.status_code == 200}
# Legacy spelling you'll still see (deprecated — migrate on sight):
# @app.on_event("startup")
# async def start(): ...
# One lifespan function keeps setup and teardown adjacent and testable —
# TestClient runs the full lifespan too: with TestClient(app) as client: ...Health Probes + the Per-Worker Reality
Liveness says the process runs; readiness says lifespan's resources actually work — orchestrators gate traffic on it. And because lifespan runs once per worker, one-time jobs must live elsewhere.
from fastapi import FastAPI, Request, Response
# ── liveness: is the process alive? (restart me if not) ──
@app.get("/health/live")
def live():
return {"status": "alive"} # no dependencies — if this 500s, restart
# ── readiness: can I actually serve? (route traffic only if yes) ──
@app.get("/health/ready")
async def ready(request: Request, response: Response):
checks = {}
try:
await request.app.state.http.get("https://api.internal/ping")
checks["upstream"] = "ok"
except Exception:
checks["upstream"] = "down"
checks["model"] = "ok" if getattr(request.app.state, "model", None) else "missing"
# real apps: SELECT 1 on the DB pool, PING on redis
if any(v != "ok" for v in checks.values()):
response.status_code = 503 # LB/K8s pulls this instance from rotation
return checks
# Kubernetes wiring (the shape interviewers expect):
# livenessProbe: httpGet /health/live → fail ⇒ container restarted
# readinessProbe: httpGet /health/ready → fail ⇒ no traffic routed
# Fly.io / Render health checks point at /health/ready the same way.
# ── lifespan runs PER WORKER PROCESS ──
# uvicorn main:app --workers 4 → 4 processes → lifespan × 4
# fine: 4 connection pools, 4 model copies (RAM permitting)
# NOT fine in lifespan: alembic upgrade, cache warm, queue seeding —
# 4 workers racing the same migration. One-time work goes in the
# deploy pipeline (release_command), not startup code.Key Points to Remember
- 1lifespan @asynccontextmanager: setup before yield, teardown after; on_event is deprecated
- 2Share app-lifetime resources (pools, clients, models) via app.state
- 3Liveness = process up; readiness = resources verified, 503 pulls the instance
- 4Lifespan runs once per worker — migrations and one-time jobs go in release steps
Interview Questions
Sign in to ask AriaWhere do you initialise an ML model in a FastAPI service, and why not at import or per request?
Liveness vs readiness probes — what does each verify and what happens on failure?
You put alembic upgrade in lifespan and deployed with 4 workers — what goes wrong?
Ask Aria about Lifespan — Startup & Shutdown Done Properly
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.