Home/Learn/FastAPI/Lifespan — Startup & Shutdown Done Properly

Lifespan — Startup & Shutdown Done Properly

Intermediate
Middleware & CORS

The 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.

Heavy things once per process: before yield up, after yield down
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.

live = restart signal, ready = traffic gate, lifespan × workers
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 Aria
1

Where do you initialise an ML model in a FastAPI service, and why not at import or per request?

MediumFractal
2

Liveness vs readiness probes — what does each verify and what happens on failure?

MediumHotstar
3

You put alembic upgrade in lifespan and deployed with 4 workers — what goes wrong?

HardRazorpay

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.

Loading discussion…