Lifespan Events

Work that happens once per process rather than once per request - and where a connection pool actually belongs.

Overview

Two lifetimes

Almost everything in this track has been per request: a body, a session, a user, a background task.

Some things are not. A database connection pool is created once and used by every request. So is an HTTP client, a machine-learning model, a cache client, a loaded configuration.

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, and where a connection 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.

example_01.pyFastAPI
Output

Sharing state with the handlers

Anything created at startup is put where handlers can reach it — app.state is the conventional place.

example_02.pyFastAPI
Output

A dependency hands it to the handler

Reaching through request.app.state works and reads poorly. A dependency gives the handler the thing itself.

example_03.pyFastAPI
Output

Per process, not per request

The startup body runs once however many requests arrive. That is the distinction that decides what belongs here.

example_04.pyFastAPI
Output

Failing at startup

An exception before the yield stops the application coming up, which is the right moment to discover a missing setting.

example_05.pyFastAPI
Output

Startup or import?

Work at import runs whenever the module is imported — including by a test collector. Startup runs when the application actually starts.

example_06.pyFastAPI
Output

The shape

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

app = FastAPI(lifespan=lifespan)

An async context manager. Everything before the yield runs when the application starts; everything after runs when it stops. In between, it serves requests.

The shape is deliberately the same as a yield dependency, with a different lifetime: that one is per request, this one is per process.

What the server actually does

Before serving anything, uvicorn sends the application a lifespan.startup message and waits for it to complete. After the last request, it sends lifespan.shutdown.

That is a real protocol, not a framework convention, which is why the editors above can drive it by hand: build the scope, send the message, and watch the same code run that uvicorn would have triggered.

Once per process, and per worker

The startup body runs once, however many requests arrive. That is the distinction that decides what belongs in it.

Worth being precise about the plural: production usually runs several worker processes, and each is a separate process with its own lifespan. Four workers means four pools, four model loads, four caches. Anything expensive is paid for four times, and anything that must be unique across the application — a scheduler, a migration, a leader election — must not be in a lifespan, because 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 convention is app.state:

app.state.pool = pool

and in a handler, request.app.state.pool.

That works and reads badly, and it couples every handler to the storage location. A dependency fixes both:

def get_pool(request: Request) -> Pool:
    return request.app.state.pool

def modules(pool: Pool = Depends(get_pool)):
    ...

Now the handler receives a Pool, knows nothing about app.state, and can be tested by overriding one dependency. That is the shape worth using.

Failing loudly

An exception before the yield prevents the application from starting.

That is the right behaviour and the right moment. A missing database URL, an unreachable dependency, an invalid configuration — all are better as a process that refuses to start than as an application that accepts traffic and fails every request.

Validating 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 equivalent and is not.

A test collector imports your application module to find the app object. So does a documentation generator, a linter with type checking, and anything that inspects routes. If connecting to a database happens at import, all of those need a database.

Startup runs when the application actually starts — which a test can choose to do, or not.

The practical rule: define things at import, *create* them at startup. pool = None at module level and the real construction in the lifespan.

Shutdown is not guaranteed

Worth knowing before relying on it.

Graceful shutdown runs the code after the yield. A SIGKILL, a container OOM, or a hardware failure does not. Anything whose absence would corrupt state must not depend on shutdown running.

In practice: close connections there because it is tidy, and do not *rely* on it for correctness. Anything that must be consistent should be consistent at every moment, not reconciled on the way out.

The older spelling

You will 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, where the relationship between them is visible, and because it can hold state in local variables rather than globals.

New code should use lifespan. Old code is worth migrating when touched.

Mistakes people make

Connecting at import. Every test collector, linter and documentation generator then needs a live database. Define at import; create at startup.

Assuming it runs once per application. 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.

Relying on shutdown. A SIGKILL, an OOM or a hardware failure skips it. Close things there for tidiness; do not depend on it for correctness.

Reaching for request.app.state in every 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 produce an application that accepts traffic and fails every request.

Using @app.on_event. Deprecated. The lifespan context manager keeps setup and teardown in one function where their relationship 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 useful. 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 difference explains the common confusion of a test failing because app.state.pool does not 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 category is the one that causes incidents, and the fix is not 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, so 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, and that shutdown does not raise. One such test per application is usually enough, and it catches the class of failure where everything passes and the process will not boot.

Summary

A lifespan is an async context manager: before the yield is startup, after it is shutdown, and in between the application serves requests.

It runs once per process - which means once per worker, so four workers build four of everything and anything that must happen exactly once does not belong there.

Create expensive, long-lived things there rather than at import, so a test collector does not need a database. 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 configuration is missing, and do not depend on shutdown running - a killed process never reaches it.

Two lifetimes, restated

Almost everything in this track has been per request. This module is the exception, and 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 produces two distinct failures. A pool created per request is catastrophic for throughput - every caller pays connection 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 category and dependencies own the second, and the yield in each has the same shape for the same reason: acquire, use, release.

Reading a startup that fails

When an application will not boot, the lifespan is usually where to look, and the failure modes are few.

A missing setting raises before the yield, and the message names the field if configuration is a validated model. This is the good case: loud, early, and specific.

An unreachable dependency - a database that is not up yet - raises a connection error. In an orchestrated deployment this is often a race rather than a fault, and the fix is a readiness probe or a retry with backoff rather than removing the check.

A blocking call in an async lifespan hangs rather than failing, which is the confusing one. The process starts, never becomes ready, and 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 will not 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 every 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 application that knows about the process rather than the request, and that makes it the natural home for a specific kind of mistake: doing something once that should happen once *per application*.

Four workers is the default shape of a production deployment, and every 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 distinction is not obvious from inside the function, because nothing about async def lifespan(app) suggests it will run four times. Knowing that it will is most of using it correctly.

Next

Testing what has been built - the client that needs no server, the overrides that remove the database, and what a suite should actually assert.

In one line

Startup and shutdown run once per worker, not once per application - so create expensive long-lived things there, hand them over through a dependency, fail loudly if configuration is missing, and put anything that must happen exactly once somewhere that runs exactly once.

One log line at the end of startup, naming what was created, turns every future boot problem into a question of which line was the last one printed.

Check yourself

0 of 4

Answer without scrolling back up.

  1. How often does a lifespan startup body run?

  2. Why prefer startup over import-time work?

  3. How should a handler get at a pool created in the lifespan?

  4. Can you rely on shutdown code always running?

Cheat sheet

Lifespan Events

Some things are not. A database connection pool is created once and used by every request. So is an HTTP client, a machine-learning model, a cache client, a loaded configuration.

FASTAPI · vizlearn.in/fastapi/lifespan_events.html

About the author

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.