FastAPI Lifespan vs Legacy Events: Choosing the Right Startup Pattern for Shared Resources
Choose FastAPI's lifespan context manager over legacy on_event handlers for reliable startup/shutdown of database pools, caches, and telemetry. This guide compares mechanisms, shows a production-ready pattern with nested async context managers, and provides validation steps.
12 Mar 2026, 08:25 UTC

The Problem: Reliable Resource Initialization in ASGI Applications
FastAPI applications that depend on database connection pools, Redis clients, or OpenTelemetry exporters need a guaranteed way to initialize these resources once per worker process and shut them down cleanly when the process receives a termination signal. The framework offers two mechanisms: the modern lifespan context manager (introduced in FastAPI 0.95.0) and the legacy on_event("startup")/on_event("shutdown") handlers. Choosing between them affects testability, error handling, and compatibility with ASGI servers like Uvicorn or Gunicorn.
Takeaway: Use the lifespan async context manager for all new applications. It provides deterministic ordering, proper exception propagation on startup failure, and clean composition of multiple resources. Reserve on_event only for trivial scripts that will never need structured shutdown or test isolation.
Decision Constraints
- ASGI server support: The server must implement the ASGI lifespan protocol (Uvicorn ≥0.15, Gunicorn with uvicorn workers, Hypercorn).
- Async-compatible clients: Resource libraries must expose async
connect/closemethods or be wrapped inrun_in_executor. - Startup failure semantics: An exception during initialization must prevent the application from accepting requests.
- Per-worker isolation: Each worker process runs its own lifespan; do not assume a single global instance in multi-worker deployments.
Supported Options Compared
| Mechanism | Protocol Integration | Error Handling | Composition | Status |
|---|---|---|---|---|
lifespan async context manager | Native ASGI lifespan protocol | Exceptions abort startup; server never becomes ready | Nested context managers or multiple yields via async with | Recommended (FastAPI ≥0.95.0) |
on_event("startup") / on_event("shutdown") | Legacy hook system | Exceptions logged but server may still start; no guaranteed rollback | Manual ordering via multiple handlers; fragile | Deprecated for new code |
| External process manager (systemd, Docker entrypoint) | Outside ASGI lifecycle | Depends on external tooling | Decoupled from app; hard to test in-process | Anti-pattern for portable apps |
Trade-offs in Practice
Deterministic Ordering and Cleanup
The lifespan context manager executes code before the yield (startup) and after the yield (shutdown) in a single async function. This guarantees that if startup fails at step three, steps one and two have already run and their cleanup logic (placed after yield) will not execute—because the context manager never yields. With on_event, each handler runs independently; a failure in the third handler leaves the first two initialized with no automatic cleanup path.
Exception Propagation
Raising an exception inside lifespan before yield bubbles up to the ASGI server, which responds by not marking the application as ready. Load balancers and health checks will see the process as unhealthy. In contrast, on_event exceptions are caught and logged by FastAPI's internal dispatcher, and the server may continue accepting traffic with partially initialized state.
Composition of Multiple Resources
You can nest context managers inside a single lifespan function:
@asynccontextmanager
async def lifespan(app: FastAPI):
async with create_pool() as pool:
async with create_redis() as redis:
async with create_tracer() as tracer:
app.state.pool = pool
app.state.redis = redis
app.state.tracer = tracer
yield
Each async with ensures its own cleanup runs in reverse order. With on_event, you must manually register shutdown handlers in reverse order and hope no handler raises during cleanup.
Per-Worker Execution
Both mechanisms run once per worker process. In a Gunicorn deployment with four workers, lifespan executes four times—creating four independent connection pools. This is correct for most resources (each worker needs its own pool), but it means you cannot rely on a single global singleton across workers. Store initialized clients in app.state or a dependency-injection container scoped to the process.
Concrete Implementation Pattern
1. Define the Lifespan Context Manager
# lifespan.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
import redis.asyncio as redis
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: create resources
engine: AsyncEngine = create_async_engine(
"postgresql+asyncpg://user:pass@localhost/db",
pool_size=10,
max_overflow=5,
)
redis_client = redis.Redis.from_url("redis://localhost:6379/0", decode_responses=True)
tracer_provider = TracerProvider()
otlp_exporter = OTLPSpanExporter(endpoint="http://localhost:4317", insecure=True)
tracer_provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
# Attach to app.state for dependency access
app.state.db_engine = engine
app.state.redis = redis_client
app.state.tracer_provider = tracer_provider
try:
yield
finally:
# Shutdown: close in reverse order
await redis_client.aclose()
await engine.dispose()
tracer_provider.shutdown()
2. Wire It Into the Application
# main.py
from fastapi import FastAPI
from lifespan import lifespan
app = FastAPI(title="Orders API", lifespan=lifespan)
@app.get("/health")
async def health():
return {"status": "ok"}
3. Access Resources Via Dependencies
# deps.py
from fastapi import Depends, Request
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import sessionmaker
import redis.asyncio as redis
async def get_db_engine(request: Request) -> AsyncEngine:
return request.app.state.db_engine
async def get_redis(request: Request) -> redis.Redis:
return request.app.state.redis
async def get_db_session(engine: AsyncEngine = Depends(get_db_engine)):
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
yield session
4. Use in Route Handlers
# routes/orders.py
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
import redis.asyncio as redis
router = APIRouter(prefix="/orders", tags=["orders"])
@router.post("")
async def create_order(
payload: dict,
session: AsyncSession = Depends(get_db_session),
redis_client: redis.Redis = Depends(get_redis),
):
# business logic using session and redis_client
return {"id": 123}
Validation Checklist
Manual Smoke Test with Uvicorn
Run the application and observe startup logs:
uvicorn main:app --workers 1 --log-level info
Expected output includes SQLAlchemy pool creation, Redis connection, and OTLP exporter initialization before "Application startup complete." Send SIGTERM (Ctrl+C) and verify shutdown logs show Redis close, engine dispose, and tracer provider shutdown in reverse order.
Automated Test with TestClient
# test_lifespan.py
import pytest
from fastapi.testclient import TestClient
from main import app
@pytest.fixture(scope="session")
def client():
with TestClient(app) as c:
yield c
def test_resources_initialized(client):
response = client.get("/health")
assert response.status_code == 200
# Verify app.state populated
assert hasattr(app.state, "db_engine")
assert hasattr(app.state, "redis")
assert hasattr(app.state, "tracer_provider")
def test_startup_failure_prevents_ready():
# Create a variant app with failing lifespan
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def bad_lifespan(app: FastAPI):
raise RuntimeError("simulated startup failure")
yield
bad_app = FastAPI(lifespan=bad_lifespan)
with TestClient(bad_app, raise_server_exceptions=False) as c:
resp = c.get("/health")
# TestClient still returns 500, but lifespan exception is raised during __enter__
assert resp.status_code == 500
Run with pytest test_lifespan.py -v. The second test confirms that an exception before yield prevents the application from becoming ready.
Limitations and Gotchas
- Blocking I/O in lifespan stalls the event loop. If a library only offers synchronous initialization (e.g., a legacy DB driver), wrap it:
await asyncio.get_event_loop().run_in_executor(None, sync_init). - Mixing lifespan with on_event causes duplicate initialization. FastAPI will run both; avoid defining
on_event("startup")when usinglifespan. - TestClient lifespan behavior. By default,
TestClienttriggers lifespan on__enter__and__exit__. Passlifespan="off"to disable for unit tests that don't need real resources. - Multi-worker deployments. Each worker runs its own lifespan. Do not write to a shared file or assume a single cache instance across workers.
How to Verify in Production
- Deploy with Gunicorn:
gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker - Check logs for four distinct startup sequences (one per worker).
- Send
SIGTERMto the master process (kill -TERM <master-pid>) and confirm each worker logs its shutdown sequence. - Query a health endpoint that returns
app.stateresource status (e.g., pool checked-out connections) to verify runtime health.
Summary
The lifespan async context manager is the supported, future-proof way to manage shared resources in FastAPI. It integrates with the ASGI lifespan protocol, provides structured error handling, and composes cleanly for multiple resources. Migrate legacy on_event handlers to lifespan to gain deterministic startup/shutdown ordering and reliable test isolation.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.