Lifespan Events vs Startup and Shutdown in FastAPI
Key takeaways:
@app.on_event("startup")is deprecated; thelifespanasync context manager replaces both events.- Everything before
yieldfinishes before the first request; everything after runs once traffic has stopped. - A
try/finallyaround theyieldgives you reverse-order teardown for free. - Hold shared clients on
app.state, or yield a mapping that lands onrequest.state. TestClientonly runs the lifespan when used as a context manager.
This guide is part of Application Factory Patterns, which covers building the app object; this page covers what happens to it either side of serving traffic.
The Problem This Solves
Your app needs one httpx.AsyncClient and one database pool, created once and shared by every request, then closed cleanly when the pod is drained. With @app.on_event this is spread across four decorated functions that communicate through module-level globals, no linter can tell you the shutdown handler closes something the startup handler created, and the whole thing is deprecated.
Worse, the split makes correct teardown accidental. If startup created three resources and the second one failed, shutdown still runs and tries to close all three, so you get an AttributeError in a shutdown handler masking the real failure.
Why It Happens
The ASGI specification defines a lifespan scope alongside http and websocket. Before binding a port, the server opens that scope and sends a lifespan.startup message; the application replies lifespan.startup.complete when it is ready. On shutdown the server sends lifespan.shutdown and waits for lifespan.shutdown.complete.
Starlette implements that protocol by driving an async context manager. Your lifespan function is that context manager: entering it corresponds to startup, the yield is the point at which the server is told it may accept connections, and exiting corresponds to shutdown. The on_event decorators were a compatibility layer over the same protocol — Starlette collected the registered callables into lists and awaited them in registration order on each side.
That difference in shape is where the ordering guarantee comes from. Two lists of callables give you order within each phase but no relationship between the phases: nothing connects the third startup handler to the third shutdown handler. A single function with a yield in the middle gives you Python's own scoping — the resource acquired on line three is still in scope after the yield, and a finally block runs whether the server stopped cleanly or the startup of a later resource blew up.
The yielded value matters too. Yield nothing and the context manager is purely for side effects. Yield a mapping and Starlette merges it into the ASGI scope's state, which surfaces on request.state in every handler. That is the mechanism behind the yielded pool in the example below.
The Fix
One lifespan function, resources acquired in order, released in a finally in reverse. This example runs both styles in-process and records what actually happened:
"""Compare @app.on_event ordering with the lifespan context manager, and record the real order."""
from contextlib import asynccontextmanager
from typing import AsyncIterator
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
class FakeClient:
"""Stand-in for an httpx.AsyncClient or a database pool held for the app's lifetime."""
def __init__(self, name: str) -> None:
self.name = name
self.closed = False
async def aclose(self) -> None:
self.closed = True
log(f"closed {self.name}")
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[dict]:
log("lifespan: acquiring http client")
http = FakeClient("http client")
log("lifespan: acquiring db pool")
pool = FakeClient("db pool")
app.state.http = http
try:
yield {"pool": pool} # yielded mapping is merged into request.state
finally:
# Reverse order: release what was acquired last, first.
log("lifespan: releasing")
await pool.aclose()
await http.aclose()
modern = FastAPI(lifespan=lifespan)
@modern.get("/ping")
async def modern_ping(request: Request) -> dict[str, object]:
log("lifespan app: request served")
return {
"style": "lifespan",
"http_client_from_app_state": request.app.state.http.name,
"pool_from_request_state": request.state.pool.name,
"pool_closed_during_request": request.state.pool.closed,
}
The deprecated equivalent, for comparison — four functions, no relationship between them:
legacy = FastAPI()
@legacy.on_event("startup")
async def legacy_startup_first() -> None:
log("on_event startup #1")
@legacy.on_event("startup")
async def legacy_startup_second() -> None:
log("on_event startup #2")
@legacy.on_event("shutdown")
async def legacy_shutdown_first() -> None:
log("on_event shutdown #1")
@legacy.on_event("shutdown")
async def legacy_shutdown_second() -> None:
log("on_event shutdown #2")
Both applications were then driven through TestClient as a context manager, so their real lifespans ran:
log("--- on_event app ---")
with TestClient(legacy) as client:
client.get("/ping")
log("--- lifespan app ---")
with TestClient(modern) as client:
client.get("/ping")
RESPONSE = client.get("/ping").json()
log("--- both apps stopped ---")
The recorded order, from a real run:
$ GET /order
200 OK
{
"events": [
"--- on_event app ---",
"on_event startup #1",
"on_event startup #2",
"on_event app: request served",
"on_event shutdown #1",
"on_event shutdown #2",
"--- lifespan app ---",
"lifespan: acquiring http client",
"lifespan: acquiring db pool",
"lifespan app: request served",
"lifespan app: request served",
"lifespan: releasing",
"closed db pool",
"closed http client",
"--- both apps stopped ---"
],
"lifespan_app_response": {
"style": "lifespan",
"http_client_from_app_state": "http client",
"pool_from_request_state": "db pool",
"pool_closed_during_request": false
}
}
Read the two blocks side by side. The on_event app runs its startup handlers in registration order and its shutdown handlers in registration order — #1 then #2 in both directions. Nothing is wrong with that, but nothing about it expresses that shutdown #1 undoes startup #1 either; you are relying on the reader to notice.
The lifespan block shows the shape you want. Both resources are acquired, requests are served — twice, on the same objects — and then teardown runs closed db pool before closed http client, the reverse of acquisition. That reversal is not something the framework does for you; it is what a finally block written in the obvious order produces.
The response body confirms both handles are reachable from a request. app.state.http is the client stored as an attribute; request.state.pool came from the dictionary the lifespan yielded. And pool_closed_during_request is false, which is the assertion that matters: no request ever sees a closed handle, because the server stops accepting requests before the code after yield runs.
Verification
The fastest check is the one that catches the most common mistake — a TestClient without a with block:
def test_lifespan_runs_and_cleans_up():
with TestClient(modern) as client:
assert client.get("/ping").json()["pool_closed_during_request"] is False
pool = client.app.state.http
assert pool.closed is True # closed by the time the block exits
If that first assertion raises AttributeError: state, you have found a test constructing the client outside a context manager somewhere. It is worth a project-wide grep.
In a deployed service, verify from the log timeline. A correct lifespan produces a startup line, then the server's "application startup complete", then traffic, then your teardown lines before the process exits. If teardown lines appear after the server has already reported the port closed but the process is killed before they finish, your orchestrator's grace period is shorter than your teardown — raise terminationGracePeriodSeconds or make the teardown faster.
Trade-offs and When Not To
Slow startup delays every deploy and every restart. A lifespan that warms a large cache or runs migrations blocks the readiness of that replica. Prefer to open pools lazily where the library supports it, and move genuinely slow work to a job that runs before the rollout rather than inside it.
Startup failures should stop the deploy — usually. An exception before yield means the server never binds. That is right for a database. It is wrong for an optional dependency such as a feature-flag service, where a failure should degrade rather than block; catch that one explicitly and record the degraded state.
A mounted sub-application has its own lifespan. Starlette does not propagate the parent's lifespan into an app attached with mount, so a resource created in the parent is not created for the child. APIRouter Prefix vs Sub-Application Mounting covers what else stops at that boundary.
app.state is untyped. It is a plain namespace, so request.app.state.htpp is a runtime AttributeError rather than a type error. If that bothers you — and at scale it should — expose each resource through a dependency that reads app.state once and returns a typed handle, which also makes it overridable in tests. Best Practices for FastAPI Dependency Injection covers that pattern.
Do not mix the two mechanisms. Passing lifespan= while also registering @app.on_event handlers is not a supported combination; migrate all of them at once.
Per-request resources are a different problem entirely. A database session should be created and closed per request by a dependency, not held for the app's lifetime — see Async SQLAlchemy Session Per Request. The lifespan owns the pool; the dependency owns the session.
FAQ
Is @app.on_event deprecated?
Yes. FastAPI deprecated the on_event decorators in favour of the lifespan argument, and Starlette has done the same. They still work, but new code should use lifespan and existing code should migrate — the two mechanisms cannot be mixed on one application.
What ordering does lifespan actually guarantee?
Everything before the yield completes before the server accepts a single request, and everything after the yield runs only once the server has stopped accepting them. Within the function, statements run in the order you wrote them, so teardown in reverse acquisition order is just the natural shape of a try/finally block.
Should shared clients live on app.state or in the yielded dictionary?
Use app.state for things a background task, an exception handler or a startup check might also need, since it is reachable from request.app.state anywhere. Use the yielded mapping for values you only ever read inside a request, since it lands on request.state and is typed a little more naturally.
Does lifespan run under TestClient?
Only when you use TestClient as a context manager. A bare TestClient(app) skips the lifespan entirely, which is why tests suddenly fail with a missing attribute on app.state — wrap it in a with block and startup runs.
What happens if startup raises an exception? The lifespan fails, the ASGI server logs the error and exits without binding the port, so the application never serves traffic in a half-initialised state. That is the desired behaviour: a database that is unreachable at boot should stop the deploy, not produce a pod that 500s on every request.
Related Reading
- Up to the topic: Application Factory Patterns, where the lifespan is attached to the app.
- Factory and testing mechanics in full: FastAPI App Factory Pattern for Testing and Deployment.
- Exposing lifespan-owned resources to handlers: Best Practices for FastAPI Dependency Injection.
- Where the credentials those clients need come from: Secrets and .env Files Per Environment.
- The per-request counterpart to an app-lifetime pool: Async SQLAlchemy Session Per Request.