Home Python FastAPI Lifespan Events: Stop Using Startup/Shutdown Decorators Wrong
Intermediate 3 min · July 05, 2026

FastAPI Lifespan Events: Stop Using Startup/Shutdown Decorators Wrong

FastAPI lifespan events explained with production patterns.

N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Notes here come from systems that actually shipped.

Follow
Production
production tested
July 05, 2026
last updated
141
articles · all by Naren
Before you start⏱ 25 min
  • Python async/await basics
  • FastAPI app structure
  • Basic understanding of context managers
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

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
ChromeFirefoxSafariEdge

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 tutorial

from fastapi import FastAPI

app = FastAPI()

@app.on_event("startup")
async def open_pool():
    # BUG: if this crashes, shutdown never runs
    app.state.db_pool = await create_db_pool()

@app.on_event("startup")
async def load_cache():
    # If this crashes, open_pool's pool is leaked
    raise RuntimeError("Cache unavailable")

@app.on_event("shutdown")
async def close_pool():
    # This never runs if load_cache crashed
    await 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.

CheckoutServiceLifespan.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
30
31
32
33
34
35
# io.thecodeforge — Python tutorial

from contextlib import asynccontextmanager
from fastapi import FastAPI
import asyncpg  # production-grade PostgreSQL driver

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup: acquire resources
    print("Starting up...")
    db_pool = await asyncpg.create_pool(
        min_size=5, max_size=20,
        command_timeout=5  # seconds
    )
    app.state.db_pool = db_pool

    cache = await create_cache_client()
    app.state.cache = cache

    try:
        yield  # app runs here
    finally:
        # Shutdown: release resources — ALWAYS runs
        print("Shutting down...")
        await db_pool.close()
        await cache.close()

app = FastAPI(lifespan=lifespan)

@app.get("/health")
async def health():
    # Use app.state.db_pool safely
    async with app.state.db_pool.acquire() as conn:
        await conn.execute("SELECT 1")
    return {"status": "ok"}
Output
Starting up...
# app runs
Shutting down...
Senior Shortcut:
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 tutorial

import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup
    app.state.work_queue = asyncio.Queue()
    app.state.workers = [
        asyncio.create_task(worker(i))
        for i in range(4)
    ]
    yield
    # Shutdown with timeout
    print("Shutting down workers...")
    for task in app.state.workers:
        task.cancel()
    # Wait for tasks to finish, but don't block forever
    await 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 tutorial

import pytest
from httpx import AsyncClient, ASGITransport
from your_app import app  # your FastAPI app with lifespan

@pytest.mark.asyncio
async def test_lifespan():
    # Manually enter lifespan
    async with app.router.lifespan_context(app):
        # Startup should have created db_pool
        assert app.state.db_pool is not None
        async with AsyncClient(
            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 closed
    assert app.state.db_pool._closed

@pytest.mark.asyncio
async def test_lifespan_startup_failure():
    # Simulate startup failure by patching
    with pytest.raises(RuntimeError):
        async with app.router.lifespan_context(app):
            raise RuntimeError("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 overkill
import httpx

async def main():
    async with 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 tutorial

from fastapi import FastAPI, Depends, Request
from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app: FastAPI):
    pool = await create_pool()
    app.state.pool = pool
    yield
    await pool.close()

app = FastAPI(lifespan=lifespan)

async def get_db(request: Request):
    # Access pool from app.state
    async with request.app.state.pool.acquire() as conn:
        yield conn

@app.get("/items")
async def read_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 DecoratorsLifespan Context Manager
Guaranteed cleanup on startup failureNo — resources leakYes — finally block runs
ComposabilityMultiple decorators, order fragileSingle generator, explicit order
Deprecated?Yes (FastAPI 0.93+)No — recommended pattern
Graceful shutdown timeoutNot built-inYou control with asyncio.wait
TestabilityHard to test without running serverEasy with lifespan_context
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
OldStartupPattern.pyfrom fastapi import FastAPIWhy the Old @app.on_event Decorators Are Dangerous
CheckoutServiceLifespan.pyfrom contextlib import asynccontextmanagerThe Lifespan Context Manager
GracefulShutdown.pyfrom contextlib import asynccontextmanagerGraceful Shutdown
TestLifespan.pyfrom httpx import AsyncClient, ASGITransportTesting Lifespan Events
SimpleScript.pyasync def main():When Not to Use Lifespan
DependencyInjection.pyfrom fastapi import FastAPI, Depends, RequestAdvanced

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.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What is the difference between @app.on_event and lifespan in FastAPI?
02
How do I test FastAPI lifespan events?
03
How do I gracefully shutdown a FastAPI app with background tasks?
04
Can I use lifespan with multiple workers?
COMPLETE GUIDE
FastAPI Complete Guide — Interactive Tutorial for Production APIs →

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
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Notes here come from systems that actually shipped.

Follow
Verified
production tested
July 05, 2026
last updated
141
articles · all by Naren
🔥

That's Python Libraries. Mark it forged?

3 min read · try the examples if you haven't

Previous
FastAPI OpenAPI Customization — Tags, Examples and Schema
54 / 57 · Python Libraries
Next
FastAPI Streaming Responses and File Responses