Filter concepts by levelShowing all levels.

Python · Section 37

ASGI, WSGI, Uvicorn and Gunicorn

Level
intermediate
Read
150 min
Concepts
6

The interface between a Python web application and the server that runs it — WSGI (synchronous) versus ASGI (async, WebSockets, lifespan events) — and the two server processes that implement it in production: Uvicorn (the ASGI event loop) and Gunicorn (process supervision), plus the reverse proxy, worker model, and graceful-shutdown discipline around them.

Python overview

What is true here

  1. WSGI defines one synchronous callable(environ, start_response); ASGI defines an async callable(scope, receive, send) and adds WebSockets and lifespan events.
  2. Uvicorn runs the event loop an ASGI app needs; Gunicorn supervises multiple worker processes — they are commonly combined, not substitutes for each other.
  3. A reverse proxy handles TLS termination, static files, and slow-client buffering in front of the app server, which should not do that itself.
  4. Worker count trades memory and startup cost against concurrent-request capacity — a starting formula, not a fixed rule, and needs measuring under real load.
  5. Graceful shutdown means catching SIGTERM, refusing new requests, and letting in-flight ones finish — not dying mid-request when a deploy restarts the process.

What you will be able to do

  • Explain the difference between a WSGI and an ASGI application callable, and why only ASGI supports WebSockets
  • Run an ASGI app with Uvicorn, and explain what its event loop is actually doing
  • Run a production deployment with Gunicorn supervising Uvicorn workers, and explain what each process is responsible for
  • Explain why a reverse proxy sits in front of the app server in production, and what it is responsible for that the app server is not
  • Choose a starting worker count for a given workload and explain the sync-vs-async worker tradeoff
  • Implement graceful shutdown so a deploy does not drop in-flight requests

The application interface: WSGI and ASGI

The contract between a Python web framework and the server running it, and what ASGI adds over WSGI.

WSGI vs ASGI

coreintermediate

WSGI and ASGI are the two contracts a Python web app can implement so any compliant server knows how to call it. WSGI defines one synchronous function; ASGI defines three async functions and adds support for WebSockets and background tasks.

Think of it as

Both are a plug shape, not a brand — any WSGI server can run any WSGI app, and any ASGI server can run any ASGI app, because both sides agree on the same callable signature. WSGI's plug has one pin: a function that takes the request and returns the response, blocking until done. ASGI's plug has three: one to receive the incoming scope, one to await more messages, one to send messages back — so a single connection can send, wait, and receive without blocking every other request on the process.

python
# WSGI callable — PEP 3333
def app(environ, start_response):
    start_response(status, headers)
    return [body_bytes]

# ASGI callable — ASGI spec, three async parameters
async def app(scope, receive, send):
    message = await receive()
    await send({"type": "http.response.start", ...})
    await send({"type": "http.response.body", ...})

What we're doing: Run both a WSGI app and an ASGI app by simulating exactly what the server side of each contract does, without opening a real socket.

wsgi_vs_asgi_demo.pypython
import asyncio

def wsgi_app(environ, start_response):
    start_response("200 OK", [("Content-Type", "text/plain")])
    return [f"Hello, {environ['PATH_INFO']}".encode()]

captured = {}
def fake_start_response(status, headers, exc_info=None):
    captured["status"] = status

result = wsgi_app({"PATH_INFO": "/world"}, fake_start_response)
print("WSGI:", captured["status"], b"".join(result).decode())


async def asgi_app(scope, receive, send):
    await receive()
    await send({"type": "http.response.start", "status": 200,
                 "headers": [(b"content-type", b"text/plain")]})
    await send({"type": "http.response.body",
                 "body": f"Hello, {scope['path']}".encode()})

async def run_asgi():
    sent = []
    async def receive():
        return {"type": "http.request", "body": b""}
    async def send(message):
        sent.append(message)
    await asgi_app({"type": "http", "path": "/world"}, receive, send)
    return sent

for msg in asyncio.run(run_asgi()):
    print("ASGI:", msg)
3
wsgi_app takes environ (a dict of request data) and start_response (a callback to set status/headers) — one synchronous call, one return value.
15
asgi_app takes scope, receive, and send — three parameters instead of two, and the function itself is async.
17
The app awaits receive() to get the next event instead of it being handed in as an argument — this is what lets it wait for more data without blocking the whole process.
19
The response goes out as two separate send() calls (start, then body) instead of one return value — this is what lets ASGI stream a response.
Output
WSGI: 200 OK Hello, /world
ASGI: {'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]}
ASGI: {'type': 'http.response.body', 'body': b'Hello, /world'}

Why this works: Both callables do the same job — read the request, write a 200 with a text body — but WSGI does it as one function call that returns everything at once, while ASGI does it as a sequence of awaited messages. That difference is the entire reason ASGI exists: a WSGI worker cannot do anything else while wsgi_app runs, but an ASGI app can await receive() and let the event loop serve other connections while it waits.

Trying to run an ASGI app directly with a WSGI server

Wrong

python
# app.py — an ASGI app (e.g. FastAPI)
async def app(scope, receive, send):
    ...

# gunicorn app:app   ← default sync worker expects a WSGI callable

Better

python
# gunicorn -k uvicorn.workers.UvicornWorker app:app
# or, without Gunicorn at all:
# uvicorn app:app

What you see: TypeError: app() missing 2 required positional arguments: 'start_response' — Gunicorn's default sync worker calls app(environ, start_response), the WSGI shape, but an ASGI app only accepts (scope, receive, send).

Why: Gunicorn's built-in sync worker only speaks WSGI. An ASGI app needs either an ASGI-aware server (Uvicorn) or Gunicorn told to load an ASGI worker class (uvicorn.workers.UvicornWorker) that translates between Gunicorn's process management and Uvicorn's ASGI event loop.

One blocking call vs. three cooperating async calls

WSGI

  • +app(environ, start_response)
  • +Returns the full response body at once
  • +Blocks the worker until the request finishes
  • +No native WebSocket or streaming support

ASGI

  • async def app(scope, receive, send)
  • Sends response parts as separate messages
  • Awaits I/O instead of blocking the whole worker
  • Same interface handles HTTP, WebSocket, and lifespan
  • WSGI
    • app(environ, start_response)
    • Returns the full response body at once
    • Blocks the worker until the request finishes
    • No native WebSocket or streaming support
  • ASGI
    • async def app(scope, receive, send)
    • Sends response parts as separate messages
    • Awaits I/O instead of blocking the whole worker
    • Same interface handles HTTP, WebSocket, and lifespan

WSGI vs ASGI at a glance

WSGI vs ASGI at a glance
AspectWSGIASGI
Callable shapeapp(environ, start_response)async def app(scope, receive, send)
Concurrency modelOne thread/process blocks per requestOne event loop handles many connections concurrently
WebSocketsNot supportedSupported via scope["type"] == "websocket"
Startup/shutdown hooksNot part of the speclifespan scope: startup/shutdown events
Typical serversGunicorn (sync worker), uWSGIUvicorn, Daphne, Hypercorn
Typical frameworksFlask, Django (sync views)FastAPI, Starlette, Django (async views)

Together

python
# WSGI: one synchronous function, called once per request
def wsgi_app(environ, start_response):
    start_response("200 OK", [("Content-Type", "text/plain")])
    return [f"Hello, {environ['PATH_INFO']}".encode()]

# ASGI: one async function, called once per connection
async def asgi_app(scope, receive, send):
    assert scope["type"] == "http"
    await receive()
    await send({"type": "http.response.start", "status": 200,
                 "headers": [(b"content-type", b"text/plain")]})
    await send({"type": "http.response.body",
                 "body": f"Hello, {scope['path']}".encode()})

Remember: WSGI: one sync function, app(environ, start_response). ASGI: one async function with three parameters, app(scope, receive, send), adding WebSockets and lifespan events.

See also: uvicorn · gunicorn · application lifecycle and graceful shutdown · asynchronous programming

Advertisement

The server processes: Uvicorn and Gunicorn

The two processes that actually implement the interface in production, and how they are typically combined.

Uvicorn

standardintermediate

Uvicorn is an ASGI server — it opens the network socket, accepts connections, and calls your async app(scope, receive, send) function for each one. FastAPI and Starlette apps run under Uvicorn.

Think of it as

An ASGI app is a function; Uvicorn is what actually listens on a port and decides when to call that function. Without a server like Uvicorn, app(scope, receive, send) is just a callable sitting in a file — nothing invokes it for an incoming request.

bash
# Development — auto-reload on file changes
uvicorn main:app --reload

# Production-shaped — bind, multiple workers, no reload
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4

# Run from Python instead of the CLI
python -c "import uvicorn; uvicorn.run('main:app', host='0.0.0.0', port=8000)"

What we're doing: Configure Uvicorn programmatically with uvicorn.Config and confirm the real kwargs it accepts for host, port, worker count, and graceful shutdown timeout.

uvicorn_config_check.pypython
import uvicorn

cfg = uvicorn.Config(
    "main:app",
    host="0.0.0.0",
    port=8000,
    workers=4,
    timeout_graceful_shutdown=30,
    proxy_headers=True,
)
print(cfg.host, cfg.port, cfg.workers, cfg.timeout_graceful_shutdown)
3
"main:app" is module:attribute — Uvicorn imports main.py and looks up the app object inside it.
6
workers=4 is only meaningful when running via the CLI or uvicorn.run — Config itself just stores the value.
7
timeout_graceful_shutdown caps how long Uvicorn waits for in-flight requests to finish before force-killing on shutdown.
Output
0.0.0.0 8000 4 30

Why this works: uvicorn.Config accepts these exact keyword names — they map one-to-one onto the CLI flags (--host, --port, --workers, --timeout-graceful-shutdown), so a config object and a CLI invocation are two ways of setting the same underlying values.

Remember: uvicorn main:app runs an ASGI app; --reload is for development, --workers N is the closest Uvicorn gets to multi-process production use on its own.

See also: wsgi vs asgi · gunicorn · worker and thread models

Gunicorn

standardintermediate

Gunicorn ("Green Unicorn") is a WSGI process manager. It forks multiple worker processes, restarts any that crash or hang, and hands each request to one worker.

Think of it as

Gunicorn is a supervisor, not a request handler itself — it decides how many workers to run and keeps that number alive, while each worker's own worker class (sync, gthread, or an ASGI adapter like Uvicorn's) decides how a single worker actually handles a request.

bash
# WSGI app, 4 sync worker processes
gunicorn -w 4 main:app

# WSGI app, threaded workers (each process handles several requests concurrently)
gunicorn -w 4 --worker-class gthread --threads 4 main:app

# ASGI app (e.g. FastAPI) — Gunicorn supervises, Uvicorn's worker class runs the event loop
gunicorn -w 4 -k uvicorn.workers.UvicornWorker main:app --bind 0.0.0.0:8000

What we're doing: Read the exact Gunicorn CLI flags and defaults for worker count, worker class, threads, and graceful shutdown timeout, as documented.

gunicorn_flags.shbash
# -w / --workers        default: 1
# -k / --worker-class    default: 'sync'   (choices: sync, gthread, gevent, tornado, or a class path)
# --threads              default: 1        (only used by the gthread worker class)
# -t / --timeout         default: 30       (seconds a worker may be silent before Gunicorn kills+restarts it)
# --graceful-timeout     default: 30       (seconds a worker gets to finish in-flight work after a restart/shutdown signal)
# -b / --bind            default: '127.0.0.1:8000'
# --preload              default: False    (load app code once before forking workers)

gunicorn -w 4 -k uvicorn.workers.UvicornWorker --graceful-timeout 30 --bind 0.0.0.0:8000 main:app
2
Gunicorn's own default worker count is 1 — production deployments always override this explicitly, commonly to (2 * CPU cores) + 1.
3
worker-class defaults to 'sync': one request at a time per worker process, no threading.
5
timeout is a liveness check — a worker that goes silent (e.g. stuck on a slow synchronous call) past this many seconds gets killed and replaced.
6
graceful-timeout is different: it applies during a deliberate restart/shutdown, giving in-flight requests time to finish instead of being cut off immediately.

Why this works: Every flag here maps to a documented Gunicorn setting with a stated default — none of it is Uvicorn's job. Gunicorn owns process count and lifecycle; the worker class (sync, gthread, or Uvicorn's ASGI worker) owns how one process actually serves requests.

Confusing --timeout with --graceful-timeout

Wrong

bash
# Intending "give slow requests 60s to finish on deploy" —
# but this raises the SILENCE timeout, not the shutdown grace period
gunicorn -w 4 --timeout 60 main:app

Better

bash
gunicorn -w 4 --graceful-timeout 60 main:app

What you see: A worker stuck on one unusually slow (but legitimate) request now takes up to 60s before Gunicorn notices and restarts it — --timeout was raised for the wrong reason and the deploy is now slower to recover from a genuinely hung worker.

Why: --timeout is a health check: how long a worker may go silent before Gunicorn assumes it is hung and kills it. --graceful-timeout is unrelated: how long a worker gets to wrap up in-flight requests once Gunicorn has already decided to restart or stop it. Raising the wrong one weakens hang detection instead of extending shutdown grace.

Remember: Gunicorn supervises worker processes (-w N) and restarts dead ones; -k picks the worker class — sync/gthread for WSGI, uvicorn.workers.UvicornWorker to host an ASGI app.

See also: uvicorn · wsgi vs asgi · worker and thread models · application lifecycle and graceful shutdown

Workers, worker processes, and threads

coreintermediate

A "worker" is one OS process that a server like Gunicorn spawns to handle requests. Each worker can be single-threaded (sync), multi-threaded (gthread), or async (an event loop handling many connections without extra threads) — the worker class picks which.

Think of it as

Picture N cashiers (worker processes) at N registers. A sync worker is a cashier who serves one customer completely before calling the next — simple, but a slow customer blocks everyone behind them. A threaded (gthread) worker is a cashier juggling a few customers by working on whichever one is ready, switching when one waits (e.g. checking a card). An async worker is a single cashier so fast at switching between waiting customers that it looks like many registers, entirely inside one process — the switching happens at every await instead of only when the OS interrupts a thread.

bash
# processes            gunicorn -w N ...
# threads per process   gunicorn --worker-class gthread --threads N ...
# async event loop       gunicorn -k uvicorn.workers.UvicornWorker ...
#                        uvicorn main:app --workers N   (N processes, each its own loop)

What we're doing: Show why an async worker (one event loop) can hold many concurrent connections without extra OS threads, using the stdlib event loop directly.

one_loop_many_connections.pypython
import asyncio
import time

async def handle_request(conn_id, delay):
    print(f"conn {conn_id}: waiting on I/O")
    await asyncio.sleep(delay)          # simulates an awaited DB/HTTP call
    print(f"conn {conn_id}: done")
    return conn_id

async def main():
    start = time.perf_counter()
    # One event loop, three "connections" in flight concurrently —
    # no extra threads or processes were created for this.
    results = await asyncio.gather(
        handle_request(1, 0.2),
        handle_request(2, 0.2),
        handle_request(3, 0.2),
    )
    elapsed = time.perf_counter() - start
    print(f"handled {results} in {elapsed:.2f}s")

asyncio.run(main())
4
handle_request represents one connection's work — the kind of function an ASGI app runs per request.
6
await asyncio.sleep(delay) simulates waiting on I/O (a DB query, an HTTP call) — this is where the event loop switches to another connection instead of blocking.
12
asyncio.gather runs all three concurrently on ONE event loop — no threads, no extra processes — the same mechanism an async worker uses across many real connections.
Output
conn 1: waiting on I/O
conn 2: waiting on I/O
conn 3: waiting on I/O
conn 1: done
conn 2: done
conn 3: done
handled [1, 2, 3] in 0.20s

Why this works: All three 0.2s waits overlap — total time is ~0.2s, not 0.6s — because the event loop starts connection 2's work the instant connection 1 hits an await, rather than waiting for connection 1 to fully finish. That is exactly the mechanism an async worker (Uvicorn, or Gunicorn + UvicornWorker) uses to serve many concurrent requests from a single OS process: no thread is blocked, so nothing needs to be created per connection.

Adding more Gunicorn workers to fix a CPU-bound bottleneck

Wrong

bash
# CPU already at 100%, response times still high — "just add more workers"
gunicorn -w 32 main:app   # on a 4-core machine

Better

bash
# Match workers to cores, and move CPU-heavy work off the request path
gunicorn -w 9 main:app   # (2 * 4 cores) + 1
# CPU-bound work itself belongs in a background task queue, not more workers

What you see: Throughput does not improve past a point, and may get worse — 32 processes now compete for 4 CPU cores, adding context-switch overhead without adding capacity.

Why: More worker processes only help while there is spare CPU capacity or the bottleneck is I/O wait, not CPU work. Once every core is saturated by actual computation, extra processes just add scheduling overhead. A common starting point is (2 × CPU cores) + 1 workers; genuinely CPU-bound work needs fewer, busier workers plus offloading the work itself — not more processes.

Three ways one worker process serves more than one request

Multiple worker processes

N independent OS processes; uses N CPU cores; one crashing does not take the others down

sync worker

one process = one request in flight at a time

gthread worker

one process = N threads = N blocking calls in flight

async worker (event loop)

one process = one loop = many awaited connections in flight

  1. Multiple worker processes — N independent OS processes; uses N CPU cores; one crashing does not take the others down
  2. sync worker — one process = one request in flight at a time
  3. gthread worker — one process = N threads = N blocking calls in flight
  4. async worker (event loop) — one process = one loop = many awaited connections in flight

Worker models compared

Worker models compared
ModelConcurrency unitBest forLimit
sync (default)One process, one request at a timeSimple, mostly-fast synchronous viewsOne slow request blocks the whole worker
gthreadOne process, N threadsBlocking I/O-bound work (sync DB/HTTP calls)GIL still serializes CPU-bound Python code
async (Uvicorn / UvicornWorker)One process, one event loop, many tasksAsync I/O-bound apps (FastAPI, httpx, asyncpg)A single blocking call in the loop stalls every connection on that worker
Multiple worker processesN independent processesUsing more than one CPU core; fault isolationEach process has its own memory — no shared in-process state

Together

bash
# 4 processes x 1 thread each = 4 requests truly in flight at once
gunicorn -w 4 main:app

# 4 processes x 4 threads each = up to 16 in-flight blocking calls
gunicorn -w 4 --worker-class gthread --threads 4 main:app

# 4 processes x one event loop each = up to thousands of concurrent
# connections per process, for I/O-bound async code
gunicorn -w 4 -k uvicorn.workers.UvicornWorker main:app

Remember: A worker is an OS process; sync = 1 request/process, gthread = N threads/process (GIL-limited for CPU work), async = 1 event loop/process handling many awaited connections.

See also: gunicorn · uvicorn · gil effects and when to use what · thread and process pool executors · asynchronous programming

Advertisement

Production operation

What sits in front of the app server, and the shutdown discipline that keeps a deploy from dropping requests.

Reverse proxies in front of a WSGI/ASGI app

standardintermediate

A reverse proxy (Nginx, a cloud load balancer) sits between the internet and Gunicorn/Uvicorn. It terminates TLS, serves static files directly, and buffers slow clients — jobs Python's own server was never built to do efficiently.

Think of it as

Gunicorn and Uvicorn are built to run your application code, not to be the internet-facing edge of a system. A reverse proxy is the receptionist: it handles the slow, unpredictable, high-volume parts of talking to the outside world — a client on a bad connection trickling in a request over seconds, a request for a logo image — before anything reaches an application worker, which stays free for actual application logic.

text
Internet
   ↓  (TLS terminates here)
Nginx / load balancer   — serves static files, buffers clients, sets X-Forwarded-*
   ↓  (plain HTTP, internal network)
Gunicorn (process manager)

Uvicorn workers (ASGI event loop)

FastAPI / Starlette app

What we're doing: Uvicorn only trusts X-Forwarded-* headers from an explicitly allowed proxy address — confirm the real config keys that control this.

proxy_headers_config.pypython
import uvicorn

cfg = uvicorn.Config(
    "main:app",
    proxy_headers=True,
    forwarded_allow_ips="10.0.0.5",   # only trust this proxy's IP
)
print(cfg.proxy_headers, cfg.forwarded_allow_ips)
5
proxy_headers=True tells Uvicorn to read X-Forwarded-For/X-Forwarded-Proto at all — off by default would mean the app sees the proxy's own IP/scheme, not the real client's.
6
forwarded_allow_ips restricts which upstream IP is trusted to set those headers — without this, anyone who can reach the app directly could spoof their own IP.

Why this works: Trusting forwarded headers from every source would let a client bypass IP-based rate limiting or logging simply by setting X-Forwarded-For itself. Scoping trust to the known proxy address is what makes the headers a safe source of the real client's info.

Remember: A reverse proxy terminates TLS, serves static files, and buffers slow clients in front of Gunicorn/Uvicorn — configure proxy_headers/forwarded_allow_ips so the app trusts only that proxy's forwarded headers.

See also: uvicorn · gunicorn · application lifecycle and graceful shutdown

Application lifecycle, graceful shutdown, and connection handling

coreintermediate

A server's lifecycle has three phases: startup (open resources like a DB pool once), serving (handle connections), and shutdown. Graceful shutdown means finishing in-flight requests before exiting, instead of cutting them off the instant a stop signal arrives.

Think of it as

Think of closing a shop at closing time versus flipping the sign and walking out mid-transaction. Startup is unlocking and turning the lights on once, before any customer arrives. Graceful shutdown is finishing the customers already at the register, refusing new ones, then locking up — not slamming the door on someone mid-purchase. An ASGI app gets explicit startup/shutdown events to do its version of "turn the lights on/off"; the server (Uvicorn/Gunicorn) is what decides how long to wait for the last customers before locking up regardless.

python
# ASGI lifespan protocol, at the wire level
async def app(scope, receive, send):
    if scope["type"] == "lifespan":
        while True:
            message = await receive()
            if message["type"] == "lifespan.startup":
                ...                                    # open resources
                await send({"type": "lifespan.startup.complete"})
            elif message["type"] == "lifespan.shutdown":
                ...                                    # close resources
                await send({"type": "lifespan.shutdown.complete"})
                return

What we're doing: Drive the raw ASGI lifespan protocol end to end — startup event in, startup-complete out; shutdown event in, shutdown-complete out — the mechanism FastAPI's lifespan= wraps.

lifespan_protocol_demo.pypython
import asyncio

resources = {}

async def app(scope, receive, send):
    if scope["type"] != "lifespan":
        return
    while True:
        message = await receive()
        if message["type"] == "lifespan.startup":
            resources["db_pool"] = "connected"
            print("startup: opened", resources["db_pool"])
            await send({"type": "lifespan.startup.complete"})
        elif message["type"] == "lifespan.shutdown":
            print("shutdown: closing", resources["db_pool"])
            resources.clear()
            await send({"type": "lifespan.shutdown.complete"})
            return

async def simulate_server():
    events = [{"type": "lifespan.startup"}, {"type": "lifespan.shutdown"}]
    sent = []
    async def receive():
        return events.pop(0)
    async def send(message):
        sent.append(message)
    await app({"type": "lifespan"}, receive, send)
    return sent

for msg in asyncio.run(simulate_server()):
    print("server saw:", msg)
5
The app checks scope["type"] == "lifespan" to handle this specially — it is a different scope from a normal HTTP request.
9
lifespan.startup arrives exactly once, before the server routes any HTTP requests to this app — the correct place to open a DB pool.
14
lifespan.shutdown arrives once, after the server has stopped accepting new connections and drained in-flight ones — the correct place to close what startup opened.
Output
startup: opened connected
server saw: {'type': 'lifespan.startup.complete'}
shutdown: closing connected
server saw: {'type': 'lifespan.shutdown.complete'}

Why this works: This is the exact protocol Uvicorn speaks to any ASGI app at process start and stop, and what a framework's lifespan()/on_event("startup") wraps in a friendlier API. Startup runs once, before the first real request; shutdown runs once, only after the server has already stopped taking new connections and drained the in-flight ones — so a DB pool closed here is not closed out from under a request still being served.

Opening a resource per-request instead of once at startup

Wrong

python
# FastAPI, no lifespan — opens a new pool on every single request
@app.get("/users")
async def get_users():
    pool = await create_pool()   # new connections every call
    rows = await pool.fetch("SELECT * FROM users")
    await pool.close()
    return rows

Better

python
@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.pool = await create_pool()   # once, at startup
    yield
    await app.state.pool.close()           # once, at shutdown

app = FastAPI(lifespan=lifespan)

@app.get("/users")
async def get_users():
    return await app.state.pool.fetch("SELECT * FROM users")

What you see: Latency per request includes a full connection setup, and under load the database rejects new connections once the pool/connection limit is exhausted — each request opened its own instead of sharing one pool.

Why: The lifespan startup event exists specifically so expensive, reusable resources are created once per process, not once per request. Skipping it and creating a resource inside the request handler pays that setup cost on every single call and can exhaust the database's own connection limit under concurrent load.

From SIGTERM to process exit
withingraceful-timeouttimeout exceeded→ force-kill

Serving requests

SIGTERM received

Stop accepting new connections

Drain in-flight requests

lifespan.shutdown — close DB pool etc.

Process exits

  • Serving requests
    • leads to SIGTERM received
  • SIGTERM received
    • leads to Stop accepting new connections
  • Stop accepting new connections
    • leads to Drain in-flight requests
  • Drain in-flight requests
    • leads to lifespan.shutdown — close DB pool etc. (within graceful-timeout)
    • on error, leads to Process exits (timeout exceeded → force-kill)
  • lifespan.shutdown — close DB pool etc.
    • leads to Process exits
  • Process exits

The three lifecycle moments and what handles each

The three lifecycle moments and what handles each
MomentSignal / eventWhat happensConfigured by
StartupASGI lifespan.startup eventApp opens DB pools, caches, background tasks — once, before the first requestFramework's startup handler (e.g. FastAPI lifespan context)
Shutdown requestedSIGTERM (process manager) or SIGINT (Ctrl+C)Server stops accepting new connections, begins draining in-flight onesUvicorn/Gunicorn's signal handler
Graceful drainIn-flight requests still runningExisting requests get to finish, up to a capped waittimeout_graceful_shutdown (Uvicorn) / --graceful-timeout (Gunicorn)
Forced stopDrain timeout exceededRemaining connections are cut off; process exitsSame timeout setting, as an upper bound
TeardownASGI lifespan.shutdown eventApp closes DB pools, flushes buffers — the mirror of startup's setupFramework's shutdown handler

Together

python
from contextlib import asynccontextmanager
from fastapi import FastAPI

@asynccontextmanager
async def lifespan(app: FastAPI):
    db_pool = await create_pool()   # startup: runs once, before requests
    app.state.db_pool = db_pool
    yield
    await db_pool.close()           # shutdown: runs once, after draining

app = FastAPI(lifespan=lifespan)

Remember: Lifespan startup/shutdown events open and close resources exactly once per process; SIGTERM starts a graceful drain capped by a timeout (Gunicorn --graceful-timeout, default 30s) — configure the timeout and give the deploy tool at least that long before force-killing.

See also: wsgi vs asgi · uvicorn · gunicorn · reverse proxies

Advertisement