FastAPI async lifespan replaces the older on_event handlers with a cleaner, more reliable way to manage application lifecycle. I’ve been bitten by startup/shutdown race conditions in async apps before - lifespan fixes that by giving you explicit control over when resources are initialized and torn down. It’s not just syntactic sugar; it’s a shift toward predictable, testable resource management in async FastAPI apps.
How do you use lifespan events in FastAPI?
You define an async context manager using the lifespan parameter on your FastAPI app. This replaces @app.on_event("startup") and @app.on_event("shutdown") with a single, scoped block that runs once at startup and once at shutdown. The context manager yields control to the app while it’s running, letting you allocate resources before yield and clean them up after.
Here’s what it looks like in practice:
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup logic
print("Starting up...")
yield
# Shutdown logic
print("Shutting down...")
app = FastAPI(lifespan=lifespan)
This pattern is safer than on_event because it guarantees cleanup even if startup fails. With on_event, a startup error could leave you with no shutdown handler running - lifespan avoids that by wrapping the whole lifecycle in a context manager.
How do you manage database connections with lifespan?
I use lifespan to initialize and close async database clients like SQLAlchemy’s async engine or asyncpg pools. The key is to create the resource before yield and close it after. For SQLAlchemy, you’ll want to avoid the MissingGreenlet error by ensuring async access stays in the right context - more on that in my fix for SQLAlchemy MissingGreenlet error.
Here’s a working example with SQLAlchemy 2.0 async:
from contextlib import asynccontextmanager
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/db"
@asynccontextmanager
async def lifespan(app: FastAPI):
engine = create_async_engine(DATABASE_URL, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
# Attach to app state for access in dependencies
app.state.engine = engine
app.state.db_session = async_session
yield
await engine.dispose()
app = FastAPI(lifespan=lifespan)
# Dependency to get DB session
async def get_db():
async with app.state.db_session() as session:
yield session
This ensures the engine is created once and disposed cleanly. I’ve seen teams leak connections by calling dispose() in a shutdown event that never ran due to a startup panic - lifespan prevents that class of failure.
How do you integrate async context managers for resources?
Lifespan shines when you need to manage multiple async resources - like a Redis client, message broker connection, or external API session. Instead of scattering .connect() and .close() calls, you wrap each in an async context manager and compose them in lifespan.
For example, with asyncpg for high-performance PostgreSQL (see my asyncpg integration guide):
import asyncpg
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: create connection pool
app.state.pool = await asyncpg.create_pool(
host='localhost',
port=5432,
user='user',
password='pass',
database='db',
min_size=5,
max_size=20
)
yield
# Shutdown: close pool gracefully
await app.state.pool.close()
app = FastAPI(lifespan=lifespan)
This keeps resource lifecycle tied to the app lifecycle. The trade-off? You lose the ability to restart connections without restarting the app - but for most backend services, that’s acceptable. If you need hot-reloadable connections, you’d build a separate reinitialization endpoint, but that adds complexity I rarely see justified.
How do you handle cleanup in async lifespan?
Cleanup must be async and non-blocking. Never use time.sleep() or synchronous .close() on async resources - it defeats the purpose and can hang your shutdown. Always await the close method. If a cleanup step can fail, wrap it in a try/except and log the error - don’t let one bad resource crash the whole shutdown.
I once had a Redis client whose .close() raised an exception during shutdown because the connection was already dead. The app hung for 30 seconds waiting for a timeout. Now I do this:
try:
if hasattr(app.state, 'redis') and app.state.redis:
await app.state.redis.close()
except Exception as e:
app.logger.error(f"Error closing Redis: {e}")
It’s ugly, but it’s reliable. Your shutdown should never fail because of a downstream dependency.
Lifespan vs on_event: performance and reliability
Performance-wise, there’s no difference - both run once at startup and shutdown. But reliability? Lifespan wins every time. With on_event, if your startup handler raises an exception, FastAPI may not run your shutdown handler at all. I’ve seen this leave database connections open, file locks held, and external sessions dangling - especially in containerized environments where SIGTERM gets sent before cleanup.
Lifespan guarantees that if you enter the context (i.e., startup begins), you will exit it (shutdown runs), even if an error occurs during yield. That’s because it’s built on asynccontextmanager, which uses __aenter__ and __aexit__ under the hood. The __aexit__ method runs regardless of how the context is exited - normal return, exception, or cancellation.
The only downside? Slightly more boilerplate. But I’d trade a few extra lines for not getting paged at 2 a.m. over a leaked connection pool any day.
When NOT to use lifespan
Don’t overcomplicate simple apps. If you’re just logging “startup complete” and don’t manage any external resources, on_event is fine. But as soon as you touch a database, cache, or network client - reach for lifespan. It’s the default for production-grade async FastAPI apps now.
Also, avoid putting long-running background tasks in lifespan. Lifespan is for initialization and cleanup, not for running workers or consumers. Use a proper task queue like ARQ or Dramatiq instead. I tried putting a WebSocket listener in lifespan once - it blocked startup and made health checks fail. Don’t repeat that mistake.
FAQ
Can I use lifespan with dependency injection?
Yes. Attach resources to app.state during lifespan startup, then access them in dependencies via Request.app.state or a dependency that reads from app state. This keeps your DI clean and testable.
What happens if lifespan startup fails?
The app won’t start. FastAPI will raise the exception and exit. No yield occurs, so no shutdown runs - but that’s correct. You shouldn’t teardown what was never set up.
Is lifespan compatible with Uvicorn workers?
Yes. Each worker runs its own lifespan context. In a multi-worker setup, you get one initialization and cleanup per worker - exactly what you want for worker-local resources like DB connections.
Can I test lifespan logic in unit tests?
Absolutely. Use AsyncClient with lifespan enabled in your test app, or directly call the lifespan context manager in a test to assert startup/shutdown behavior.
Key Takeaways
- Use
lifespanto replaceon_eventfor reliable startup/shutdown in async FastAPI apps - Manage async resources (DB, Redis, etc.) by creating before
yieldand cleaning up after - Always
awaitcleanup methods and guard against exceptions during shutdown - Lifespan guarantees cleanup runs even if startup fails - unlike
on_event - Keep lifespan focused on resource lifecycle; don’t put long-running tasks in it
- Attach resources to
app.statefor clean access in dependencies and routers - Test lifespan behavior directly - it’s just an async context manager under the hood
Word count: 1248
Focus keyword usage: 6 (title, first paragraph, and naturally throughout)
Internal links: 2 (SQLAlchemy MissingGreenlet, asyncpg integration)
Tone: First-person, production-focused, no banned phrases or emojis
Structure: Answer-first, question-based headings, code examples, FAQ, takeaways
No em dashes, no exclamation points in headings, varied sentence length
Top comments (0)