FastAPI Lifespan vs on_event: A Practical Decision Guide
Compare FastAPI's lifespan context manager with legacy on_event hooks. Learn how to safely initialize DB pools and shared resources using app.state for better testability and ASGI compliance.
31 May 2026, 11:25 UTC

Decision Context
When a FastAPI application needs to create shared resources—such as a database connection pool, a cache client, or a background worker—those resources must be created once per process, made available to dependencies, and cleaned up cleanly when the application stops. The two mechanisms FastAPI exposes for this are the lifespan context manager introduced in FastAPI 0.93 and the legacy @app.on_event('startup') / @app.on_event('shutdown') decorators. Choosing the right approach affects resource safety, testability, and compatibility with the ASGI lifespan protocol.
Supported Options
Below is a compact comparison of the three common patterns for managing startup and shutdown logic in FastAPI.
| Mechanism | API | Lifecycle Stage | Async Support | Status |
|---|---|---|---|---|
| lifespan context manager | @asynccontextmanager + app.lifespan |
startup on enter, shutdown on exit | native async | recommended (FastAPI ≥ 0.93) |
| on_event startup/shutdown | app.add_event_handler('startup', ...) app.add_event_handler('shutdown', ...) |
separate callbacks | async supported | deprecated |
| manual init in main | code before uvicorn.run |
outside framework | depends on caller | not recommended |
Trade-offs
- Lifespan bundles init and cleanup into a single context, guaranteeing deterministic ordering and ensuring the shutdown block runs even if the process is terminated by a signal. It integrates with the ASGI lifespan protocol, so tools like Uvicorn’s reload mode or Docker’s graceful shutdown work out of the box. The trade-off is that it requires FastAPI ≥ 0.93 and Starlette ≥ 0.20, and developers must be careful to avoid blocking code inside the context manager.
- on_event is simpler to write in small scripts and has been part of FastAPI for a long time. However, it is deprecated, may generate deprecation warnings, and can lead to duplicated startup calls when the app is reloaded. The split between startup and shutdown callbacks can make reasoning about ordering harder.
- Manual init gives the developer full control but couples the application to a specific runner. It breaks testability because the dependencies cannot be injected without running the same startup code manually, and it makes the app less portable across ASGI servers.
Implementation Pattern
Below is a minimal, production-ready pattern that uses the lifespan context manager to create a database pool and expose it via app.state. Dependencies can then pull the pool from app.state without worrying about initialization.
# main.py
from fastapi import FastAPI, Depends
from fastapi.responses import JSONResponse
from contextlib import asynccontextmanager
from typing import AsyncGenerator
# Placeholder for an async DB pool type
class AsyncDBPool:
async def connect(self):
print("Connecting to DB...")
async def close(self):
print("Closing DB connection...")
async def init_db_pool() -> AsyncDBPool:
pool = AsyncDBPool()
await pool.connect()
return pool
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Startup: create the pool and store it in app.state
app.state.db_pool = await init_db_pool()
try:
yield
finally:
# Shutdown: close the pool
await app.state.db_pool.close()
app = FastAPI(lifespan=lifespan)
# Dependency that pulls the pool from app.state
async def get_db_pool(app: FastAPI = Depends(lambda: app)):
return app.state.db_pool
@app.get("/ping")
async def ping(db_pool=Depends(get_db_pool)):
return JSONResponse(content={"pong": True})
Key points:
- The
lifespanfunction is decorated with@asynccontextmanagerso that both startup and shutdown can be written usingawait. - Resources are stored on
app.state, a thread-safe namespace provided by Starlette. Dependencies read from this state via a simpleDependsfunction. - Because the lifespan block is part of the ASGI application, Uvicorn’s
--reloadflag will not trigger duplicate startup logic. - The pattern is fully testable: the
TestClientcan be used as a context manager to trigger both phases.
Testing and Validation
- Runtime check: Run
uvicorn main:app --log-level infoand look for the print statements insidelifespan. You should see "Connecting to DB..." before the server starts listening and "Closing DB connection..." after you press Ctrl-C. - TestClient verification:
from fastapi.testclient import TestClient from main import app with TestClient(app) as client: # The lifespan startup should have run assert hasattr(app.state, 'db_pool') response = client.get('/ping') assert response.status_code == 200 # After exiting the context, the lifespan shutdown should have run - Deprecation warning check: Import
app.add_event_handlerand attach a dummy startup function. When you run the app, you should see a deprecation warning in the console, confirming the need to migrate tolifespan.
Common Pitfalls
- Blocking I/O inside the lifespan context (e.g.,
time.sleep()) will stall the event loop. Useasyncio.to_thread()orrun_in_threadpoolfor blocking operations. - Mutating shared state in a non-thread-safe way can cause race conditions when the app is run with multiple workers. Prefer immutable objects or use proper synchronization primitives.
- When using the legacy
on_eventhooks, remember that the shutdown callback is only called if the ASGI server sends thelifespan.shutdownmessage. Some servers will not trigger it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.