Use the lifespan context manager with @asynccontextmanager to manage startup/shutdown. It ensures resources are properly acquired and released, even on crashes. The old @app.on_event decorators are deprecated and can leak resources under concurrent load.
✦ Definition~90s read
What is FastAPI Lifespan Events?
FastAPI lifespan events (startup, shutdown) let you run code when your app starts and stops. The lifespan context manager replaces the old @app.on_event decorators, giving you proper async context management for resources like database connections, thread pools, and external service clients.
★
Think of your FastAPI app as a food truck.
Plain-English First
Think of your FastAPI app as a food truck. Startup is when you open the shutters, turn on the grill, and stock the fridge. Shutdown is when you clean up, turn off the gas, and lock up. The lifespan context manager is like a checklist that guarantees you do both in order, even if a health inspector shows up mid-shift. Without it, you might leave the gas on.
⚙ Browser compatibility
Latest versions — ✓ supported
Chrome
Firefox
Safari
Edge
✓
✓
✓
✓
Everyone gets FastAPI startup/shutdown wrong. They slap @app.on_event("startup") on a function that opens a database pool, then wonder why their app crashes at 3am with 'Connection pool exhausted'. The old decorator pattern is a ticking bomb. FastAPI 0.93+ deprecated it for a reason: it doesn't compose, it doesn't handle exceptions cleanly, and it breaks under concurrent startup signals from multiple workers. The lifespan context manager fixes all of that. By the end of this article, you'll be able to write production-grade startup/shutdown code that survives SIGTERM, connection drops, and even your own deployment scripts.
Why the Old @app.on_event Decorators Are Dangerous
The old decorators look clean but hide a nasty flaw: they don't compose. If you have three startup handlers and the second one throws, the first one's resources never get cleaned up. In production, that means leaked connections, open file handles, and zombie threads. I've seen this bring down a payments service when the thread pool was exhausted at 3am because a startup handler crashed after opening a connection pool. The lifespan context manager wraps everything in a single async generator: setup before yield, teardown after. If setup fails, teardown still runs because Python's finally in the context manager triggers. This is not a minor improvement — it's the difference between a self-healing service and a pager storm.
OldStartupPattern.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# io.thecodeforge — Python tutorialfrom fastapi importFastAPI
app = FastAPI()
@app.on_event("startup")
asyncdefopen_pool():
# BUG: if this crashes, shutdown never runs
app.state.db_pool = awaitcreate_db_pool()
@app.on_event("startup")
asyncdefload_cache():
# If this crashes, open_pool's pool is leakedraiseRuntimeError("Cache unavailable")
@app.on_event("shutdown")
asyncdefclose_pool():
# This never runs if load_cache crashedawait app.state.db_pool.close()
Output
RuntimeError: Cache unavailable
# db_pool is never closed — leaked connection
Production Trap:
If any startup handler raises, all subsequent handlers are skipped, but previous handlers' resources are NOT cleaned up. You get silent leaks until OOM.
The Lifespan Context Manager: One Pattern to Rule Them All
FastAPI's lifespan parameter accepts an async generator. Everything before yield is startup; everything after is shutdown. The generator is wrapped in a context manager, so even if startup throws, the cleanup runs. This is the only pattern you should use in production. Here's how a checkout service initializes its database pool, cache client, and external payment gateway client. Notice the try/finally — belt and suspenders.
Use try/finally inside the lifespan generator even though the context manager already guarantees cleanup. It makes the intent explicit and protects against future refactors that might break the pattern.
Graceful Shutdown: Handling SIGTERM Without Dropping Requests
When Kubernetes sends SIGTERM, your app has a few seconds to finish in-flight requests and close resources. The lifespan shutdown runs after the server stops accepting new connections but before the process exits. But if your cleanup blocks (e.g., waiting for a database pool to drain), uvicorn will kill you after timeout_graceful_shutdown (default 30 seconds). The classic rookie mistake here is to await a long-running task in shutdown without a timeout. I've seen this cause a full table lock on writes because the cleanup waited indefinitely for a hung query. Always wrap cleanup in asyncio.wait_for with a timeout.
GracefulShutdown.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# io.thecodeforge — Python tutorialimport asyncio
from contextlib import asynccontextmanager
from fastapi importFastAPI
@asynccontextmanager
asyncdeflifespan(app: FastAPI):
# Startup
app.state.work_queue = asyncio.Queue()
app.state.workers = [
asyncio.create_task(worker(i))
for i inrange(4)
]
yield# Shutdown with timeoutprint("Shutting down workers...")
for task in app.state.workers:
task.cancel()
# Wait for tasks to finish, but don't block foreverawait asyncio.wait(
app.state.workers,
timeout=5.0, # seconds
return_when=asyncio.ALL_COMPLETED
)
print("Workers shut down.")
Output
Shutting down workers...
Workers shut down.
Never Do This:
await asyncio.gather(*tasks) in shutdown without a timeout. If one task hangs, your entire shutdown hangs, and uvicorn kills the process ungracefully. Always use asyncio.wait with timeout.
Testing Lifespan Events: How to Verify Startup and Shutdown
You can't test lifespan events with a simple TestClient because it doesn't trigger the lifespan by default. You need TestClient with raise_server_exceptions=False and manually enter/exit the context manager. Or use httpx.AsyncClient with the app directly. Here's how we test the checkout service lifespan: we assert that the pool is created after startup and closed after shutdown. This catches the exact bug that burned us in the incident.
TestLifespan.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# io.thecodeforge — Python tutorialimport pytest
from httpx importAsyncClient, ASGITransport
from your_app import app # your FastAPI app with lifespan
@pytest.mark.asyncio
asyncdeftest_lifespan():
# Manually enter lifespanasyncwith app.router.lifespan_context(app):
# Startup should have created db_poolassert app.state.db_pool isnotNoneasyncwithAsyncClient(
transport=ASGITransport(app=app),
base_url="http://test"
) as client:
response = await client.get("/health")
assert response.status_code == 200# After lifespan exits, pool should be closedassert app.state.db_pool._closed
@pytest.mark.asyncio
asyncdeftest_lifespan_startup_failure():
# Simulate startup failure by patchingwith pytest.raises(RuntimeError):
asyncwith app.router.lifespan_context(app):
raiseRuntimeError("Boom")
# Even after exception, cleanup should have run# In real test, check that resources are released
Output
Tests pass if lifespan works correctly.
Interview Gold:
Knowing that TestClient doesn't trigger lifespan by default is a common interview trap. Mention app.router.lifespan_context to show you've read the source.
When Not to Use Lifespan: Simple Scripts and One-Off Tasks
If you're writing a tiny internal tool that runs one request and exits, don't bother with lifespan. Just open connections in the endpoint and close them in a finally. Lifespan adds complexity that's only justified when you have multiple endpoints sharing resources, or when you need graceful shutdown. For a cron-job style script that calls one API and dies, a simple with block is cleaner. But for any service that stays running and handles concurrent requests, lifespan is non-negotiable.
SimpleScript.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
# io.thecodeforge — Python tutorial# For a one-off script, lifespan is overkillimport httpx
asyncdefmain():
asyncwith httpx.AsyncClient() as client:
response = await client.get("https://api.example.com/data")
print(response.json())
# No lifespan needed — client is scoped to the function
Output
{...}
Senior Shortcut:
If your app has only one route and no shared state, you probably don't need lifespan. But the moment you add a second route that uses the same database, refactor to lifespan before you forget.
Advanced: Lifespan with Dependency Injection and Background Tasks
Sometimes you need to pass lifespan resources to background tasks or dependencies. The cleanest pattern is to store them in app.state and access them via request.app.state in dependencies. But beware: background tasks that outlive the request may try to access app.state after shutdown. Always cancel background tasks in the lifespan shutdown, as shown earlier. Another pattern is to use Depends() with a generator that yields a resource from app.state. This keeps your endpoints clean.
DependencyInjection.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# io.thecodeforge — Python tutorialfrom fastapi importFastAPI, Depends, Requestfrom contextlib import asynccontextmanager
@asynccontextmanager
asyncdeflifespan(app: FastAPI):
pool = awaitcreate_pool()
app.state.pool = pool
yieldawait pool.close()
app = FastAPI(lifespan=lifespan)
asyncdefget_db(request: Request):
# Access pool from app.stateasyncwith request.app.state.pool.acquire() as conn:
yield conn
@app.get("/items")
asyncdefread_items(db=Depends(get_db)):
# db is a connection from the pool
result = await db.fetch("SELECT * FROM items")
return result
Output
Returns list of items from database.
Production Trap:
Never store resources in global variables. Use app.state — it's designed for this and works with multiple workers.
● Production incidentPOST-MORTEMseverity: high
The 4GB Container That Kept Dying
Symptom
A payments service container ran out of memory every 3 hours. Restarting fixed it temporarily.
Assumption
Memory leak in the business logic. Team spent 2 days profiling.
Root cause
The old @app.on_event("startup") opened a new aiohttp session on every worker start, but @app.on_event("shutdown") never closed it because an exception in another startup handler skipped the shutdown registration. Sessions accumulated until OOM.
Fix
Replaced both decorators with a single lifespan context manager. The yield cleanup runs even if startup partially fails. Added session.close() in a finally block.
Key lesson
If your startup and shutdown aren't in the same context manager, you're one exception away from a leak.
Production debug guideSystematic recovery paths for the failure modes engineers actually hit.3 entries
Symptom · 01
App fails to start with 'RuntimeError: cannot schedule new futures after shutdown'
→
Fix
1. Check if you're using @app.on_event("startup"). 2. Replace with lifespan context manager. 3. Ensure no asyncio tasks are created outside lifespan.
Symptom · 02
Database connections leak after repeated deployments
→
Fix
1. Run SELECT * FROM pg_stat_activity to count connections. 2. Verify shutdown closes pool. 3. Add pool.close() in finally block of lifespan.
Symptom · 03
Shutdown hangs for >30 seconds, then process is killed
→
Fix
1. Check for long-running background tasks. 2. Cancel tasks in shutdown. 3. Use asyncio.wait with timeout=5.0. 4. Set timeout_graceful_shutdown in uvicorn config.
★ FastAPI Lifespan Events Triage Cheat SheetFirst-response commands for when things go wrong — copy-paste ready.
App fails to start: `RuntimeError: cannot schedule new futures after shutdown`−
Immediate action
Check if using deprecated `@app.on_event`
Commands
`grep -r 'on_event' app/`
`grep -r 'lifespan' app/`
Fix now
Replace @app.on_event with lifespan context manager.
Database connections leak: `too many connections` from PostgreSQL+
Immediate action
Count connections from app
Commands
`SELECT count(*) FROM pg_stat_activity WHERE application_name = 'your_app';`
`lsof -i :5432 | grep your_app | wc -l`
Fix now
Ensure pool.close() is called in lifespan shutdown, wrapped in try/finally.
Shutdown hangs: process killed after 30s+
Immediate action
Check for background tasks
Commands
`ps aux | grep uvicorn`
`strace -p <pid> -e trace=network 2>&1 | head`
Fix now
Cancel all asyncio tasks in shutdown and use asyncio.wait with timeout.
Lifespan not running in tests: `AssertionError: app.state.pool is None`+
Immediate action
Check test client configuration
Commands
`grep -r 'TestClient' tests/`
`grep -r 'lifespan_context' tests/`
Fix now
Use async with app.router.lifespan_context(app): in tests.
Feature / Aspect
@app.on_event Decorators
Lifespan Context Manager
Guaranteed cleanup on startup failure
No — resources leak
Yes — finally block runs
Composability
Multiple decorators, order fragile
Single generator, explicit order
Deprecated?
Yes (FastAPI 0.93+)
No — recommended pattern
Graceful shutdown timeout
Not built-in
You control with asyncio.wait
Testability
Hard to test without running server
Easy with lifespan_context
⚙ Quick Reference
6 commands from this guide
File
Command / Code
Purpose
OldStartupPattern.py
from fastapi import FastAPI
Why the Old @app.on_event Decorators Are Dangerous
CheckoutServiceLifespan.py
from contextlib import asynccontextmanager
The Lifespan Context Manager
GracefulShutdown.py
from contextlib import asynccontextmanager
Graceful Shutdown
TestLifespan.py
from httpx import AsyncClient, ASGITransport
Testing Lifespan Events
SimpleScript.py
async def main():
When Not to Use Lifespan
DependencyInjection.py
from fastapi import FastAPI, Depends, Request
Advanced
Key takeaways
1
Always use the lifespan context manager, never @app.on_event
it's deprecated and leaks resources on startup failure.
2
Wrap cleanup in try/finally inside the lifespan generator
explicit intent protects against future refactors.
3
Use asyncio.wait with a timeout in shutdown to avoid hanging on unresponsive tasks.
4
Test lifespan events with app.router.lifespan_context(app)
TestClient doesn't trigger them by default.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
How does FastAPI's lifespan context manager handle exceptions during sta...
Q02SENIOR
When would you choose lifespan over a simple `with` block inside each en...
Q03SENIOR
What happens if you forget to cancel background tasks in lifespan shutdo...
Q04JUNIOR
What is the lifespan context manager and why was it introduced?
Q05SENIOR
You deploy a FastAPI service and notice database connections accumulatin...
Q06SENIOR
How would you design a FastAPI service that needs to connect to a databa...
Q01 of 06SENIOR
How does FastAPI's lifespan context manager handle exceptions during startup? What happens to resources already acquired?
ANSWER
If startup code before yield raises, the context manager's __aexit__ runs the cleanup after yield. So resources acquired before the exception are properly released. This is the key advantage over @app.on_event where cleanup is not guaranteed.
Q02 of 06SENIOR
When would you choose lifespan over a simple `with` block inside each endpoint?
ANSWER
Use lifespan when multiple endpoints share the same resource (e.g., database pool) or when you need graceful shutdown. For a single-endpoint script, a with block is simpler. Lifespan is for long-running services with shared state.
Q03 of 06SENIOR
What happens if you forget to cancel background tasks in lifespan shutdown? How do you mitigate?
ANSWER
The tasks continue running, preventing the event loop from closing. Uvicorn will wait for timeout_graceful_shutdown (default 30s) then kill the process. Mitigation: cancel all tasks in shutdown and use asyncio.wait with a timeout.
Q04 of 06JUNIOR
What is the lifespan context manager and why was it introduced?
ANSWER
It's an async generator that replaces @app.on_event decorators. It ensures startup and shutdown code are paired, and cleanup runs even on startup failure. Introduced in FastAPI 0.93 to fix resource leaks and composability issues.
Q05 of 06SENIOR
You deploy a FastAPI service and notice database connections accumulating. How do you diagnose and fix?
ANSWER
Check pg_stat_activity for connections from your app. If they increase after each deploy, the shutdown isn't closing the pool. Ensure the pool is closed in the lifespan's finally block. Also check that you're not using @app.on_event which can skip shutdown.
Q06 of 06SENIOR
How would you design a FastAPI service that needs to connect to a database and a message queue, with graceful shutdown under 5 seconds?
ANSWER
Use a single lifespan context manager. Acquire both resources before yield. In shutdown, cancel any background tasks, then close both resources with asyncio.wait timeouts. Set uvicorn's timeout_graceful_shutdown to 5. Test with SIGTERM.
01
How does FastAPI's lifespan context manager handle exceptions during startup? What happens to resources already acquired?
SENIOR
02
When would you choose lifespan over a simple `with` block inside each endpoint?
SENIOR
03
What happens if you forget to cancel background tasks in lifespan shutdown? How do you mitigate?
SENIOR
04
What is the lifespan context manager and why was it introduced?
JUNIOR
05
You deploy a FastAPI service and notice database connections accumulating. How do you diagnose and fix?
SENIOR
06
How would you design a FastAPI service that needs to connect to a database and a message queue, with graceful shutdown under 5 seconds?
SENIOR
FAQ · 4 QUESTIONS
Frequently Asked Questions
01
What is the difference between @app.on_event and lifespan in FastAPI?
Use lifespan for production services. @app.on_event is only acceptable for quick prototypes that you'll throw away.
Was this helpful?
02
How do I test FastAPI lifespan events?
Wrap your test client inside async with app.router.lifespan_context(app): to trigger startup and shutdown.
Was this helpful?
03
How do I gracefully shutdown a FastAPI app with background tasks?
Never use asyncio.gather without a timeout in shutdown — it can hang forever.
Was this helpful?
04
Can I use lifespan with multiple workers?
For shared resources like a Redis cache, consider using a connection pool that is safe for multiprocessing, or use a separate sidecar.
Every FastAPI concept with runnable in-browser examples — params, Pydantic, dependency injection, JWT auth, async, SQLAlchemy, testing, WebSockets, and Docker deployment. The interactive reference for production engineers.
N
NarenFounder & Principal Engineer
20+ years shipping production Python across data and backend systems. Notes here come from systems that actually shipped.