Work that happens once per process rather than once per request - and where a connection pool actually belongs.
Overview
Two lifetimes
A lifespan is the period between your application starting and stopping. Lifespan events are the places where you run code at each end of that period. On startup you might open a database pool or load a machine learning model. On shutdown you close the pool and flush anything pending.
This is different from per-request work. A request lasts milliseconds, and you would not want to build a connection pool for each one. Some resources are expensive to create and meant to be shared. The lifespan is where those belong, created once and reused by every request.
This article starts with the two lifetimes, per request and per process, and how to tell which one a resource needs. It then shows the shape of a lifespan function, which uses the same yield pattern you may know from dependencies. Code before the yield runs at startup, and code after it runs at shutdown.
You will also read what the server actually does when it starts and stops, which clears up a lot of confusion about timing. A section covers the fact that lifespan runs once per worker process, so a server with four workers runs it four times. That affects anything you load into memory.
The article ends with how to reach the shared resource from inside a handler, through the application state, so your endpoints can use what startup created.
Older FastAPI code used separate startup and shutdown event handlers. You will still meet them in tutorials and existing projects. The article explains why the single lifespan function replaced them, and how to read the older style if you inherit it. A good rule of thumb: if something is slow to build and safe to share, it belongs here. If it holds data for one user, it does not. Each section is short and ends with a habit you can apply straight away. You finish knowing where each shared resource belongs, how to build it once, and how to release it cleanly when the server stops.
Almost all in this track has been per request: a body, a session, a user, a background task.
Some things aren't. A DB link pool is created once and used by each request. So is an HTTP client, a machine-learning model, a cache client, a loaded config.
Creating those per request would be absurdly wasteful. Creating them at import has its own problems. Lifespan is the third option, and the correct one.
Worth knowing
A lifespan is an async context manager: everything before the yield runs at startup, everything after at shutdown.
It runs once per process, not per request — and once per worker, so four workers build four pools.
Put what it creates on app.state, and hand it to handlers through a dependency rather than reaching for request.app.state in each one.
Raising before the yield stops the application starting, which is the right moment to find a missing setting.
Prefer startup over import-time work: an import happens whenever anything loads the module, including a test collector.
@app.on_event("startup") is the older spelling and is deprecated. The lifespan context manager replaced it.
Lifespan Events
Work that happens once per process rather than once per request. Where a link pool belongs.
The lifespan protocol, driven by hand
A server sends the app a startup message before serving and a shutdown message after. This is that, without the server.
An async context manager. all before the yield runs when the use starts. All after runs when it stops. In between, it serves requests.
The shape is on purpose the same as a yield dependency, with a other lifetime. That one is per request, this one is per process.
What the server actually does
Before serving anything, uvicorn sends the use a lifespan.startup message and waits for it to full. After the last request, it sends lifespan.shutdown.
That is a real protocol, not a framework rule. This Is why the editors above can drive it by hand. Build the scope, send the message. Watch the same code run that uvicorn would have triggered.
Once per process, and per worker
The startup body runs once. But Many requests arrive. That is the gap that decides what belongs in it.
Worth being precise about the plural: production often runs a few worker processes. Each is a split process with its own lifespan. Four workers means four pools, four model loads, four caches. Anything costly is paid for four times. Anything that must be unique across the use. A scheduler, a migration, a leader election. Must not be in a lifespan. It will run once per worker rather than once.
That last point catches people. A migration in startup runs concurrently in four processes on the first deploy.
Getting at it from a handler
What startup creates has to be reachable. The rule is app.state:
app.state.pool = pool
and in a handler, request.app.state.pool.
That works and reads badly, and it couples each handler to the storage location. A dependency fixes both:
Now the handler receives a Pool, knows nothing about app.state. Can be tested by replacing one dependency. That is the shape worth using.
Failing loudly
An exception before the yield blocks the use from starting.
That is the right action and the right moment. A missing DB URL, an unreachable dependency, an invalid config. All are better as a process that refuses to start than as an use that accepts traffic and fails each request.
checking settings in the lifespan, or importing a settings model that validates itself, turns a class of runtime mystery into a startup error with a message.
Startup or import time?
Module-level code runs when the module is imported. That sounds equal and isn't.
A test collector imports your use module to find the app object. So does a docs gen, a linter with type checking. Anything that inspects routes. If connecting to a DB happens at import, all of those need a DB.
Startup runs when the use really starts. Which a test can choose to do, or not.
The hands-on rule: define things at import, *create* them at startup. pool = None at module level and the real build in the lifespan.
Shutdown is not guaranteed
Worth knowing before leaning on it.
Graceful shutdown runs the code after the yield. A SIGKILL, a holder OOM, or a hardware failure doesn't. Anything whose lack would corrupt state must not depend on shutdown running.
: close links there because it's tidy. Don't *lean* on it for correctness. Anything that must be steady should be steady at each moment, not reconciled on the way out.
The older spelling
You'll see this in existing code:
@app.on_event("startup")
async def startup(): ...
It works and is deprecated. The lifespan context manager replaced it because it keeps setup and teardown in one function. Here The link between them is visible. Because it can hold state in local vars rather than globals.
New code should use lifespan. Old code is worth migrating when touched.
Mistakes people make with Lifespan Events
Connecting at import. each test collector, linter and docs gen then needs a live DB. Define at import. Create at startup.
given it runs once per use. It runs once per worker. Four workers means four pools and four model loads - and a migration placed there runs four times concurrently on first deploy.
Leaning on shutdown. A SIGKILL, an OOM or a hardware failure skips it. Close things there for tidiness. Don't depend on it for correctness.
**Reaching for request.app.state in each handler.** It couples each one to where the object is stored. A dependency hands over the object itself and can be overridden in a test.
Swallowing startup failures. A missing setting should stop the process, not give an use that accepts traffic and fails each request.
**Using @app.on_event.** Deprecated. The lifespan context manager keeps setup and teardown in one function where their link is visible.
In tests
TestClient(app) does not run the lifespan. Used as a context manager it does:
with TestClient(app) as client:
...
Both are handy. An isolated unit test with dependencies overridden is faster and cleaner without startup. An integration test that should exercise the real wiring needs it.
Knowing the gap shows the common confusion of a test failing because app.state.pool doesn't exist - the startup that would have created it never ran.
What belongs in it
A short list, because the boundary is what the module is really about.
Yes: connection pools, HTTP clients, loaded models, caches, warmed configuration, anything expensive with a process lifetime.
No: anything per request - a session, a transaction, a user. Those are dependencies.
Definitely not: anything that must happen exactly once for the whole application. Migrations, scheduled job registration, leader election. With four workers those run four times, concurrently, on every deploy.
That last group is the one that causes incidents. The fix isn't FastAPI's. It belongs in a deployment step that runs once, before the workers start.
Testing around it
Because the lifespan is opt-in for TestClient, most unit tests skip it and are better for skipping it - dependencies are overridden anyway. The pool never needed to exist.
Where it matters is the integration test that should prove the wiring works. That startup succeeds with real settings, that what it creates is reachable. That shutdown doesn't raise. One such test per use is often enough. It catches the class of failure where all passes and the process won't boot.
Lifespan Events: summary
A lifespan is an async context manager: before the yield is startup, after it's shutdown. In between the use serves requests.
It runs once per process - which means once per worker. Four workers build four of all and anything that must happen just once doesn't belong there.
Create costly, long-lived things there rather than at import. A test collector doesn't need a DB. Put them on app.state and hand them to handlers through a dependency. So The handlers stay unaware and overridable.
Fail loudly before the yield when config is missing. Don't depend on shutdown running - a killed process never reaches it.
Two lifetimes, restated
Almost all in this track has been per request. This module is the exception. Holding the two apart is what the module is for.
Per process: the connection pool, the HTTP client, the loaded model, the cache client, the parsed configuration. Expensive, reusable, created once.
Per request: the session taken from that pool, the transaction, the user, the filters. Cheap, disposable, created and released for each caller.
Confusing them makes two distinct failures. A pool created per request is catastrophic for throughput - each caller pays link setup. A session shared across requests is catastrophic for correctness - two callers inside one transaction, seeing each other's uncommitted work.
The lifespan owns the first group and dependencies own the second. The yield in each has the same shape for the same reason: acquire, use, release.
Reading a startup that fails
When an use won't boot, the lifespan is often where to look. The failure modes are few.
A missing setting raises before the yield. The message names the field if config is a checked model. This is the good case: loud, early, and exact.
An unreachable dependency - a DB that isn't up yet - raises a link error. In an orchestrated deployment this is often a race rather than a fault. The fix is a readiness probe or a retry with backoff rather than dropping the check.
A blocking call in an async lifespan hangs rather than failing. This Is the confusing one. The process starts, never turns ready. Nothing is logged. The rule from the async module applies here too.
Work that should have run once - a migration - appears to succeed on one worker and deadlock on the others.
Knowing that list turns "it won't start" into four things to check in order.
One habit
Put a log line at the end of startup naming what was created and the settings that matter. It costs nothing and it turns each future boot problem into a question of which line was the last one printed.
A closing thought
The lifespan is the only place in a FastAPI use that knows about the process rather than the request. That makes it the natural home for a exact kind of mistake. Doing a thing once that should happen once *per use*.
Four workers is the default shape of a production deployment. Each one of them runs this code. A pool per worker is correct and intended. A migration per worker is a race. A scheduled job registered per worker is four schedulers.
The gap isn't obvious from inside the function. Nothing about async def lifespan(app) suggests it will run four times. Knowing that it will is most of using it right.
Where to go after Lifespan Events
Testing what has been built - the client that needs no server, the replaces that drop the DB. What a suite should really assert.
Questions people ask about Lifespan Events
What is lifespan? An async context manager passed to FastAPI(lifespan=...). Code before yield runs at startup and code after yield runs at shutdown.
Why is lifespan preferred over on_event? on_event is deprecated, and lifespan keeps setup and teardown in one place so they can share variables.
Where do I store what I create at startup? On app.state, or in a dict yielded from lifespan, and read it back through the request.
Does lifespan run in tests? Only when you use TestClient as a context manager (with TestClient(app) as client). Otherwise startup is skipped.
What happens if startup raises? The app does not start, and the error appears in the server log.
What belongs in lifespan? Opening connection pools, loading a model, and starting background workers, along with closing them on exit.
Lifespan Events in one line
Startup and shutdown run once per worker, not once per use - so create costly long-lived things there, hand them over through a dependency, fail loudly if config is missing. Put anything that must happen just once one spot that runs just once.
One log line at the end of startup, naming what was created, turns each future boot problem into a question of which line was the last one printed.
Check yourself
0 of 4
Answer without scrolling back up.
How often does a lifespan startup body run?
Four workers means four pools and four model loads. It also means a migration in startup runs four times concurrently on first deploy, which is why that does not belong there.
Why prefer startup over import-time work?
Connecting at import means every test run, linter and documentation generator needs a live database. Define at import, create at startup.
How should a handler get at a pool created in the lifespan?
The dependency gives the handler the object itself, keeps it unaware of where it is stored, and makes it replaceable with one override in a test.
Can you rely on shutdown code always running?
Close things there because it is tidy, but anything whose absence would corrupt state must be consistent at every moment rather than reconciled on the way out.
Cheat sheet
Lifespan Events
Write a lifespan context manager that opens a connection pool at startup and closes it on shutdown, and see why per-request setup is wasteful.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.