FastAPI’s lifespan context is surprisingly a lot like a tiny, embedded event loop running outside of your main request handling.

Let’s watch it in action. Imagine you have a background task that needs to start when your app boots up and stop cleanly when it shuts down.

import asyncio
from fastapi import FastAPI

app = FastAPI()

background_task = None

@app.on_event("startup")
async def startup_event():
    global background_task
    print("Starting background task...")
    background_task = asyncio.create_task(run_background_job())
    print("Background task started.")

@app.on_event("shutdown")
async def shutdown_event():
    global background_task
    print("Shutting down background task...")
    if background_task:
        background_task.cancel()
        try:
            await background_task
        except asyncio.CancelledError:
            print("Background task successfully cancelled.")
    print("Background task shut down.")

async def run_background_job():
    while True:
        print("Background job is running...")
        await asyncio.sleep(5)

@app.get("/")
async def read_root():
    return {"message": "Hello World"}

When you run this with uvicorn main:app --reload, you’ll see:

INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [xxxxx] using statreload
INFO:     Started server process [xxxxx]
INFO:     Waiting for application startup.
Starting background task...
Background task started.
INFO:     Application startup complete.
Background job is running...

Press CTRL+C. You’ll see:

INFO:     Shutting down: Wall time 0:00:05.123456
Shutting down background task...
Background job is running...
Background task successfully cancelled.
Shutting down background task...
INFO:     Waiting for application shutdown.
INFO:     Application shutdown complete.

The lifespan context is FastAPI’s mechanism for running code before it starts accepting requests and after it stops. This is crucial for managing resources that need initialization or cleanup, like database connections, background worker processes, or external API clients. It’s not just a "start here" and "stop here" marker; it’s an asynchronous execution context.

The startup event handler runs once when the server starts. The shutdown event handler runs once when the server is signaled to stop. These handlers are asynchronous, meaning you can await operations within them, like starting up a database connection pool or cancelling a long-running task. Uvicorn, the ASGI server, orchestrates these events. When Uvicorn starts, it calls the startup handlers. When it receives a shutdown signal (like SIGINT from CTRL+C), it waits for the shutdown handlers to complete before exiting.

The key to managing long-running tasks like run_background_job is using asyncio.create_task. This schedules the coroutine to run concurrently without blocking the startup process. In the shutdown event, we then explicitly cancel() this task and await its completion to ensure it exits gracefully. Without await background_task after cancel(), the shutdown might complete before the task has actually finished its cleanup, potentially leading to data corruption or resource leaks.

What most people don’t realize is that the lifespan context is designed to be robust even in the face of rapid restarts. If you’re using --reload with Uvicorn, the shutdown event will fire before the new process starts, ensuring a clean handover. This means your shutdown logic will execute even if the process is about to be replaced, preventing orphaned tasks or unclosed connections.

The next frontier is understanding how to manage multiple concurrent startup/shutdown tasks and handle potential errors during these phases without crashing the entire application.

Want structured learning?

Take the full Fastapi course →