Lifespan: startup and shutdown

FastAPI · Advanced · 10 min

Opening a client or loading a model per request is slow and leaks resources; per-process setup belongs in lifespan.

Some things should exist once per process, not once per request: a database engine, an httpx.AsyncClient, a Redis pool, a loaded ML model. FastAPI's tool for this is lifespan: an async context manager passed to FastAPI(lifespan=...). Code before its yield runs at startup, before the server accepts requests; code after it runs at shutdown, once requests have stopped.

Lifespan replaces the deprecated @app.on_event("startup") and @app.on_event("shutdown") handlers. Don't mix them: when you pass lifespan, on_event handlers aren't called.

Where to keep the resources? Module globals work, but lifespan state is cleaner: yield a dict, yield {"http": client}, and each request can read it as request.state.http (every request gets a shallow copy of that dict). Wrap the lookup in a dependency so endpoints stay simple and tests can override it:

def get_http(request: Request) -> httpx.AsyncClient:
    return request.state.http

Things to keep in mind:

  • Each worker process runs its own lifespan. Four workers mean four pools, so size them accordingly.
  • If startup raises, the server doesn't start. That's good: fail fast on bad configuration or an unreachable database.
  • Only the main app's lifespan runs, not those of sub-applications mounted with app.mount.
  • In tests, lifespan runs only inside with TestClient(app) as client:.

Clean up in reverse order of creation, and put the cleanup after yield in a try/finally if you open several resources, or use contextlib.AsyncExitStack.

Example

from contextlib import asynccontextmanager
from typing import Annotated
import httpx
from fastapi import Depends, FastAPI, Request
from fastapi.testclient import TestClient

@asynccontextmanager
async def lifespan(app: FastAPI):
    client = httpx.AsyncClient(timeout=5)
    print("startup: client created")
    yield {"http": client}          # becomes request.state.http
    await client.aclose()
    print("shutdown: client closed")

app = FastAPI(lifespan=lifespan)

def get_http(request: Request) -> httpx.AsyncClient:
    return request.state.http

@app.get("/client")
async def which_client(http: Annotated[httpx.AsyncClient, Depends(get_http)]):
    return {"closed": http.is_closed}

with TestClient(app) as c:              # runs startup ... shutdown
    print(c.get("/client").json())      # {'closed': False}

Remember

  • lifespan = async context manager: before yield is startup, after is shutdown
  • Yield a dict to expose resources as request.state.<name>
  • Each worker process runs its own lifespan
  • Use lifespan instead of the deprecated on_event handlers

Watch out for

  • Creating an httpx.AsyncClient or DB engine inside each request instead of once in lifespan.

Check yourself

  1. You run the app with 4 workers and the lifespan creates a DB pool of 10 connections. How many connections can the app open?
  2. Where do resources yielded from lifespan as a dict show up?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: fastapi.tiangolo.com/advanced/events/