Filter concepts by levelShowing all levels.

Django · Section 61

ASGI vs WSGI

Level
advanced
Read
24 min
Concepts
2

WSGI and ASGI are calling conventions between a server and a Python application, not servers and not frameworks. WSGI is a single call — `application(environ, start_response)` returning an iterable of bytes — which describes exactly one request and one response, so a worker is occupied for a request's full duration and WebSockets are not merely missing but unrepresentable: there is no channel in which a later message could arrive. ASGI replaces the call with `await application(scope, receive, send)`, where `scope` describes the connection and the two awaitable channels carry messages over time, so a connection becomes a stream and HTTP is one protocol expressed in that shape while WebSocket is another. ASGI is a superset in capability, so sync Django views run under it unchanged with Django adapting them into a thread — which makes migration a deployment change plus a middleware audit rather than a rewrite. `startproject` generates both `wsgi.py` and `asgi.py`, and the *worker class*, not the module name, decides the protocol. On deployment, Gunicorn is a pre-fork process manager whose worker class selects WSGI or ASGI, and Uvicorn can also run standalone. The concurrency model differs accordingly: under WSGI it comes from processes, so a slow endpoint occupies a whole worker, while under ASGI it comes from the event loop, so adding workers multiplies database connections and context switching without adding capacity. Shutdown is the part that quietly loses work — readiness must fail before `SIGTERM`, in-flight requests must finish inside `graceful_timeout`, and the platform's own grace period must exceed it, or requests are `SIGKILL`ed with nothing written to any log.

What is true here

  1. WSGI is one call and one response; ASGI is a scope plus awaitable receive/send channels.
  2. WebSockets and streaming are unrepresentable in WSGI, not merely unsupported.
  3. The worker class chooses the protocol — pointing a sync worker at asgi.py is a calling-convention mismatch.
  4. WSGI concurrency comes from processes; ASGI concurrency comes from the event loop.
  5. The shutdown chain must agree: readiness, SIGTERM, graceful_timeout, platform grace period — in that order of increasing length.

What you will be able to do

  • Explain what ASGI adds to WSGI in terms of the protocol shape, not just "async"
  • Read a generated `asgi.py` and know why the imports are ordered the way they are
  • Choose a worker class and worker count from where the concurrency actually comes from
  • Configure a shutdown that does not drop in-flight requests on every deploy
From the socket to your view, and what each layer decides

Load balancer / orchestrator

terminationGracePeriodSeconds — the outermost timer, and it must be the longest

Gunicorn master

forks and supervises workers; on SIGTERM stops accepting and starts the graceful window

Worker class — the protocol decision

default worker speaks WSGI; UvicornWorker speaks ASGI. The module name does not choose this.

WSGI: application(environ, start_response)

one call, one response, one request per worker at a time

ASGI: await application(scope, receive, send)

a scope plus message channels — many connections per worker, and WebSockets become expressible

Django middleware chain

one sync entry forces a thread switch per request and erases the async gain

Your view

sync or async — Django adapts whichever does not match the interface it is running under

  1. Load balancer / orchestrator — terminationGracePeriodSeconds — the outermost timer, and it must be the longest
  2. Gunicorn master — forks and supervises workers; on SIGTERM stops accepting and starts the graceful window
  3. Worker class — the protocol decision — default worker speaks WSGI; UvicornWorker speaks ASGI. The module name does not choose this.
  4. WSGI: application(environ, start_response) — one call, one response, one request per worker at a time
  5. ASGI: await application(scope, receive, send) — a scope plus message channels — many connections per worker, and WebSockets become expressible
  6. Django middleware chain — one sync entry forces a thread switch per request and erases the async gain
  7. Your view — sync or async — Django adapts whichever does not match the interface it is running under

The two interfaces

What each calling convention can express, and the two application objects every project already has.

WSGI and ASGI as interfaces, and Django's two application objects

coreintermediate

WSGI and ASGI are *calling conventions* between a server and a Python application — they are not servers, and they are not frameworks. WSGI is the older, synchronous one: the server calls `application(environ, start_response)`, gets an iterable of bytes back, and that is the whole protocol, so one request occupies one worker for its full duration. ASGI is the async-capable successor: the server awaits `application(scope, receive, send)`, where `scope` describes the connection, and `receive`/`send` are awaitable channels for messages. That message-passing shape is what lets ASGI carry things WSGI structurally cannot — WebSockets, server-sent events, long-lived connections and background lifespan hooks. `startproject` generates both `wsgi.py` and `asgi.py`; which one you point your server at is a deployment decision.

Think of it as

The difference is not "sync versus async" so much as "one call versus a conversation". WSGI models a request as a single function call that returns a response, which is a complete description of HTTP request/response and nothing else — there is no way to express "the client sent another frame" or "the connection is still open", so WebSockets are not an omission from WSGI, they are unrepresentable in it. ASGI replaces the single call with a scope plus two awaitable channels, so a connection becomes a stream of messages the application can read from and write to over time. HTTP is then one protocol expressed in that shape, and WebSocket is another. Two practical consequences follow. First, ASGI is a superset in capability, so a sync Django view runs fine under ASGI — Django adapts it into a thread — which means migrating is a deployment change plus a middleware audit rather than a rewrite. Second, `asgi.py` and `wsgi.py` both exist in every generated project and are not interchangeable at the server level: a WSGI server cannot serve `asgi.py`, and pointing Gunicorn at the wrong module produces a startup error rather than a subtly degraded site.

bash
gunicorn config.wsgi:application                                  # WSGI
gunicorn config.asgi:application -k uvicorn.workers.UvicornWorker  # ASGI

What we're doing: Serve HTTP and WebSocket from one ASGI application, so both share the Django settings, apps and ORM.

config/asgi.pypython
import os
from django.core.asgi import get_asgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
django_asgi_app = get_asgi_application()      # must be built BEFORE importing
                                              # anything that touches models

from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from notifications.routing import websocket_urlpatterns

application = ProtocolTypeRouter({
    "http": django_asgi_app,
    "websocket": AuthMiddlewareStack(URLRouter(websocket_urlpatterns)),
})
4–6
The order is load-bearing: `get_asgi_application()` populates the app registry, so any model import above this line raises `AppRegistryNotReady`.
8
The imports sit below the call for the same reason. This is one of the few places where an import genuinely cannot go at the top of the file.
13
`scope["type"] == "http"` routes to the ordinary Django application, so every existing view, middleware and URL keeps working unchanged.
14
WebSocket connections take a different branch entirely — the shape ASGI adds, and the reason a WSGI deployment cannot serve them at all.

Why this works: One ASGI entry point serving both protocols means the WebSocket side shares settings, the app registry and the ORM with the HTTP side, rather than being a second service that has to duplicate all three.

Importing models above `get_asgi_application()`

Wrong

python
import os
from notifications.consumers import NotificationConsumer   # imports models
from django.core.asgi import get_asgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_asgi_application()

Better

python
import os
from django.core.asgi import get_asgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
django_asgi_app = get_asgi_application()

from notifications.consumers import NotificationConsumer   # now safe

What you see: `django.core.exceptions.AppRegistryNotReady: Apps aren't loaded yet.` at startup — under an ASGI server only, while `manage.py runserver` may work, because it initialises the registry by a different path.

Why: `get_asgi_application()` is what calls `django.setup()` and populates the app registry. Any module imported before it that touches `models` asks the registry for something it has not built yet. This is the same rule as `wsgi.py`, but it bites harder here because ASGI routing usually needs consumer imports in the same file — which is why the imports go *below* the call.

One call, or a conversation

WSGI — a function call

  • +The server calls the app once and reads the returned iterable.
  • +The worker is occupied for the whole request, whatever it is waiting on.
  • +There is nowhere to put a message that arrives later — so no WebSockets.
  • +Concurrency is entirely a matter of how many workers you run.
  • +Still the right answer for a purely synchronous, request/response application.

ASGI — a scope and two channels

  • scope describes the connection; receive and send carry messages over time.
  • HTTP is one protocol in this shape; WebSocket is another.
  • A worker can hold many connections at once if nothing blocks the loop.
  • Sync Django views still work — Django adapts them into a thread.
  • Required for streaming, long-polling, and anything long-lived.
  • WSGI — a function call
    • The server calls the app once and reads the returned iterable.
    • The worker is occupied for the whole request, whatever it is waiting on.
    • There is nowhere to put a message that arrives later — so no WebSockets.
    • Concurrency is entirely a matter of how many workers you run.
    • Still the right answer for a purely synchronous, request/response application.
  • ASGI — a scope and two channels
    • scope describes the connection; receive and send carry messages over time.
    • HTTP is one protocol in this shape; WebSocket is another.
    • A worker can hold many connections at once if nothing blocks the loop.
    • Sync Django views still work — Django adapts them into a thread.
    • Required for streaming, long-polling, and anything long-lived.

The two interfaces, side by side

The two interfaces, side by side
PropertyWSGIASGI
Signature`application(environ, start_response)``await application(scope, receive, send)`
Shapeone call, one responsea scope plus two message channels
Concurrency within a workernone — one request per workermany, if the stack is async throughout
WebSockets / SSEunrepresentablesupported as another protocol in `scope["type"]`
Sync Django viewsnativerun in a thread, adapted by Django
Async Django viewsrun in a one-off loop, no gainnative
ServerGunicorn (sync workers), uWSGIUvicorn, Hypercorn, Daphne, Gunicorn + Uvicorn worker

Together

python
# config/asgi.py — generated by startproject
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_asgi_application()

Remember: WSGI and ASGI are calling conventions, not servers. WSGI is one call returning one response, which is why WebSockets are unrepresentable in it rather than merely absent. ASGI is a scope plus awaitable `receive`/`send` channels, so a connection becomes a stream of messages and HTTP is just one protocol in that shape. ASGI is a superset — sync views run under it unchanged — so migrating is a deployment change plus a middleware audit. And `asgi.py` needs an ASGI worker class: the module name does not choose the protocol.

See also: servers workers and lifecycle · async views and the asgi requirement · asgi py · wsgi py

Advertisement

Servers, workers, and shutdown

Gunicorn and Uvicorn, where concurrency comes from, and the timers that have to agree.

Uvicorn, Gunicorn, workers, graceful shutdown, and the connection lifecycle

coreadvanced

Gunicorn is a process manager: a master forks worker processes, restarts them when they die, and hands each one a socket. Its default worker speaks WSGI; with `-k uvicorn.workers.UvicornWorker` each worker instead runs Uvicorn, so you get Gunicorn's supervision and ASGI's protocol. Uvicorn can also run alone, which is simpler in a container where the orchestrator already supervises. Worker count is about CPU and memory, not about how many users you have — the usual starting point is `2 × cores + 1` for sync workers. Graceful shutdown is the part people skip: on `SIGTERM` the master stops accepting, lets in-flight requests finish within `graceful_timeout`, and only then kills what is left.

Think of it as

Think of concurrency as coming from exactly two places, and know which one you are using. Under WSGI it comes from *processes*: one worker serves one request at a time, so throughput is worker count divided by request duration, and a slow endpoint consumes a worker for its whole duration no matter how idle that worker is while waiting. Under ASGI a worker holds many connections at once, so concurrency comes from the event loop instead — which means the worker count is now about CPU parallelism and memory, and adding workers to fix a latency problem stops being the answer. The lifecycle matters most at shutdown, and this is where deploys quietly lose work. `SIGTERM` starts the graceful window; `graceful_timeout` bounds it; anything still running when it expires gets `SIGKILL` and simply vanishes mid-request. Two things have to line up for that to be safe: the orchestrator's own termination grace period must be *longer* than Gunicorn's, or the platform kills the container before Gunicorn finishes being polite, and the load balancer must stop sending new connections before the process starts refusing them — which is what a readiness probe that fails first is for.

bash
gunicorn config.asgi:application -k uvicorn.workers.UvicornWorker \
  --workers 4 --graceful-timeout 30 --max-requests 1000 --max-requests-jitter 100

What we're doing: A worker configuration whose shutdown, timeouts and platform grace period actually agree with each other.

deploy/gunicorn.conf.pypython
bind = "0.0.0.0:8000"
worker_class = "uvicorn.workers.UvicornWorker"
workers = int(os.getenv("WEB_CONCURRENCY", os.cpu_count()))

# A worker killed for exceeding this loses its request with no traceback.
timeout = 30

# The window in which in-flight requests may finish after SIGTERM.
graceful_timeout = 30

# Recycle workers to bound the effect of any slow memory growth.
# Jitter stops every worker recycling on the same request count.
max_requests = 1000
max_requests_jitter = 100

preload_app = False        # each worker imports the app itself, so a reload
                           # replaces workers one at a time

# deploy/k8s.yaml (excerpt)
#   terminationGracePeriodSeconds: 45      <- MUST exceed graceful_timeout
#   readinessProbe: { httpGet: { path: /healthz/ }, periodSeconds: 5 }
2
The worker class is what makes this ASGI. The module in `bind`/the command line does not choose the protocol — this line does.
6
`timeout` kills a worker that has not finished a request. It is a liveness guard, not a request deadline: the request dies without an exception or a log line, which is why a shorter database `statement_timeout` underneath it is what makes slowness diagnosable.
9
Distinct from `timeout`: this one bounds the *shutdown* window, and is the number the platform grace period has to exceed.
13–14
Recycling bounds any per-worker memory growth. The jitter stops all four workers reaching 1,000 requests together and recycling simultaneously.
20–21
Forty-five against thirty. Setting them equal means the platform SIGKILLs the container at the exact moment Gunicorn was going to finish gracefully.

Why this works: The three numbers form a chain — statement timeout inside request timeout inside graceful timeout inside the platform grace period — and each one exists to make the layer above it diagnosable rather than mysterious.

A platform grace period shorter than `graceful_timeout`

Wrong

yaml
# gunicorn: --graceful-timeout 30
terminationGracePeriodSeconds: 10     # the platform kills the pod at 10s

Better

yaml
# gunicorn: --graceful-timeout 30
terminationGracePeriodSeconds: 45

What you see: Every deploy produces a small burst of 502s and a handful of requests that simply never completed — no application error, no traceback, and the count scales with how busy the service was at the moment of the rollout.

Why: Two independent shutdown timers are running, and the shorter one wins. Gunicorn is politely waiting up to thirty seconds for in-flight work while the orchestrator sends `SIGKILL` at ten, which terminates the process mid-request. Because `SIGKILL` cannot be handled, nothing is logged on the way out — the request just stops existing. The platform value has to be the outer bound.

A deploy, from SIGTERM to the last byte written
  1. T-10s

    Readiness probe starts failing

    The load balancer stops sending NEW connections while the process is still serving the ones it has.

  2. T+0

    SIGTERM reaches the Gunicorn master

    The master stops accepting on the listening socket and forwards the signal to every worker.

  3. T+0

    Workers finish what they are holding

    In-flight requests run to completion. No new request is dispatched to a worker that is shutting down.

  4. T+~2s

    Idle workers exit immediately

    A worker with nothing in flight does not wait out the window — it exits as soon as its last response is written.

  5. T+30s

    graceful_timeout expires

    Anything still running is SIGKILLed. The request dies mid-flight: no response, no exception, nothing in the application log.

  6. T+30s

    The orchestrator grace period must still be running

    If terminationGracePeriodSeconds is 30 and graceful_timeout is 30, the platform kills the container at the same moment — set the platform value higher.

  7. After

    The next release accepts traffic

    Overlapping the two is what makes the deploy zero-downtime; the old process must outlive its own in-flight work, not the deploy.

  1. T-10s: Readiness probe starts failing — The load balancer stops sending NEW connections while the process is still serving the ones it has.
  2. T+0: SIGTERM reaches the Gunicorn master — The master stops accepting on the listening socket and forwards the signal to every worker.
  3. T+0: Workers finish what they are holding — In-flight requests run to completion. No new request is dispatched to a worker that is shutting down.
  4. T+~2s: Idle workers exit immediately — A worker with nothing in flight does not wait out the window — it exits as soon as its last response is written.
  5. T+30s: graceful_timeout expires — Anything still running is SIGKILLed. The request dies mid-flight: no response, no exception, nothing in the application log.
  6. T+30s: The orchestrator grace period must still be running — If terminationGracePeriodSeconds is 30 and graceful_timeout is 30, the platform kills the container at the same moment — set the platform value higher.
  7. After: The next release accepts traffic — Overlapping the two is what makes the deploy zero-downtime; the old process must outlive its own in-flight work, not the deploy.

Where concurrency comes from, per deployment

Where concurrency comes from, per deployment
DeploymentConcurrent requests per workerScale byA slow endpoint
Gunicorn sync workers1more workersoccupies a whole worker
Gunicorn `gthread`threads per workerworkers × threadsoccupies a thread
Gunicorn + UvicornWorkermany, if nothing blockscores (workers) + the loopoccupies a coroutine
Uvicorn standalonemany, if nothing blockscontainer replicasoccupies a coroutine

Together

bash
gunicorn config.asgi:application \
  -k uvicorn.workers.UvicornWorker \
  --workers 4 --timeout 30 --graceful-timeout 30 --max-requests 1000

Remember: Gunicorn supervises processes and the *worker class* chooses the protocol; Uvicorn can also run alone. Under WSGI concurrency comes from workers, so a slow endpoint occupies one; under ASGI it comes from the event loop, so extra workers add connections and context switching rather than capacity. Every worker is a separate process — separate memory, separate connections, separate `LocMemCache`. And make the shutdown chain agree: readiness fails first, then `SIGTERM`, then `graceful_timeout`, all inside a longer platform grace period, or requests are `SIGKILL`ed with nothing in the log.

See also: wsgi and asgi as interfaces · async views and the asgi requirement · server errors and gateway codes

Advertisement