Running Django Under ASGI with Uvicorn¶
Async views only run asynchronously under an ASGI server; under WSGI, Django runs them in a one-off event loop per request and gains nothing. Moving a Django project from gunicorn's sync workers to an ASGI server changes how it handles waiting, how it holds database connections, and — less obviously — what it logs. Measured with Django 6.1.1 on Python 3.14 serving the same views four ways: for 1,000 requests that each waited 100 ms at 200 concurrent, four gunicorn sync workers delivered 39 requests per second with a p50 of 5.1 s, while uvicorn delivered 1,546 with one worker and 1,603 with four, and gunicorn running uvicorn workers delivered 1,603. For fast requests the picture flipped: gunicorn's sync workers served a hello-world view at 4,962 requests per second against 4,262–4,498 for four uvicorn workers. Two configuration surprises came with the switch: uvicorn reported ASGI 'lifespan' protocol appears unsupported., and with a typical Django LOGGING setting, uvicorn printed no log lines at all — not even its startup messages. This guide makes the move without those surprises.
Prerequisites¶
- Django 4.2+ (tested on 6.1.1),
pip install uvicorn gunicorn uvicorn-worker. - Async views, from writing async views in Django.
- Worker sizing, from sizing uvicorn workers for async services.
1. Serve the ASGI application¶
Every Django project has an asgi.py next to wsgi.py; point the server at it:
# project/asgi.py (generated by startproject)
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")
application = get_asgi_application()
# one process, for development or behind a process manager
uvicorn project.asgi:application --host 0.0.0.0 --port 8000
# several workers under gunicorn's process management
gunicorn project.asgi:application -k uvicorn_worker.UvicornWorker -w 4 -b 0.0.0.0:8000
The uvicorn-worker package provides the gunicorn worker class (it used to ship inside uvicorn as uvicorn.workers.UvicornWorker). Gunicorn as the parent adds graceful reloads, worker restarts after a request count, and familiar process management; plain uvicorn --workers N is simpler and was within measurement noise of it for waiting-heavy views. Any ASGI server works — Hypercorn and Granian also serve Django — as compared in choosing between uvicorn, Hypercorn and Granian.
Verify: the server's startup output names the ASGI application, and an async view reports asyncio.get_running_loop() without error.
2. Know where ASGI wins and where it does not¶
The measurements split cleanly by what requests do. For requests that wait — upstream calls, slow queries, long polls — sync workers can only wait as many times at once as they have threads; four workers handled four slow requests at a time, and the other 196 queued for seconds. ASGI workers waited for all of them concurrently. For requests that compute — fast queries, template rendering, JSON serialization — the per-request overhead decides, and sync workers were as fast or faster.
# The deciding question for a Django deployment:
# what fraction of request time is spent waiting on something outside the process?
#
# mostly waiting (APIs, long queries, streaming) -> ASGI pays off dramatically
# mostly CPU (render, serialize, fast indexed queries) -> sync WSGI is as good
A mixed site benefits from ASGI if even a minority of its endpoints wait for a long time, because under WSGI those endpoints occupy workers that fast requests then queue behind — the head-of-line effect behind the 5.1 s p50. If the slow endpoints are few, isolating them on a separate ASGI deployment while the rest stays on WSGI is also a reasonable design.
Verify: a load test of your slowest waiting endpoint, at realistic concurrency, compares p99 latency on both servers before you switch.
3. Stop Django's LOGGING from silencing the server¶
Django applies the LOGGING setting with logging.config.dictConfig, whose disable_existing_loggers defaults to True. Uvicorn configures its loggers before Django loads, so Django's configuration disables them:
LOGGING = {
"version": 1,
"disable_existing_loggers": False, # keep uvicorn.error and uvicorn.access
"handlers": {"console": {"class": "logging.StreamHandler"}},
"loggers": {
"django.request": {"handlers": ["console"], "level": "ERROR"},
"uvicorn.error": {"handlers": ["console"], "level": "INFO", "propagate": False},
},
}
Measured: with disable_existing_loggers left at its default, uvicorn at --log-level info wrote 0 lines during startup, a request and shutdown; with it set to False, 8 lines, including the access log and Application startup complete. Under uvicorn that can hide server-level errors entirely, so check the setting as part of the switch. Structured logging for async services is covered in structured logging for async services.
Verify: after a deploy, the server's startup line and per-request access lines appear in the log stream.
4. Replace lifespan hooks with lazy initialization¶
ASGI servers send a lifespan startup event so applications can open pools and clients before serving. Django does not implement it, and uvicorn says so at startup: ASGI 'lifespan' protocol appears unsupported. That is harmless, but it means there is no Django-native place for async startup work:
import asyncio
import httpx
_client: httpx.AsyncClient | None = None
_lock = asyncio.Lock()
async def get_client() -> httpx.AsyncClient:
global _client
if _client is None:
async with _lock:
if _client is None:
_client = httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=2.0))
return _client
Create async resources lazily on first use, guarded by a lock so concurrent first requests do not create several — the pattern in lazy async initialization with a shared future. Synchronous startup work can still go in AppConfig.ready(). Database connections need no startup step: Django's pool, configured in DATABASES, opens connections on demand. Startup hooks that must run before the first request — warming caches, checking dependencies — belong in a readiness probe instead, as in implementing health and readiness probes for asyncio.
Verify: the first request after a deploy creates exactly one client, and later requests reuse it.
5. Set database connections for ASGI before switching¶
The most consequential configuration change is the database. Under WSGI, CONN_MAX_AGE reuses a connection per worker thread across requests. Under ASGI, each request's database work runs on a thread created for that request, and persistent connections accumulate:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"CONN_MAX_AGE": 0, # not 60 under ASGI
"OPTIONS": {"pool": {"min_size": 2, "max_size": 20}}, # Django 5.1+, psycopg 3
}
}
Measured: with CONN_MAX_AGE=60 under uvicorn, PostgreSQL's 100-connection limit was reached within a few hundred requests and further requests failed with sorry, too many clients already; with the pool, connections stayed at 20 or fewer and throughput rose to 538 requests per second from 341 without it. Details and the transaction caveats are in using the async Django ORM. Size the pool per worker: four workers with max_size=20 can hold 80 connections.
Verify: under a load test after the switch, pg_stat_activity connections stay below workers × max_size.
Verification¶
Django is ready for ASGI in production when:
- The ASGI application is served by uvicorn, gunicorn with
UvicornWorker, or another ASGI server. - Load tests of waiting endpoints show the expected concurrency gain.
disable_existing_loggersisFalse, and server logs appear.CONN_MAX_AGEis 0 with a pool, and async resources are created lazily.
Diagnostic Hook: after the switch, watch three numbers together for a day: database connections, p99 latency of the slowest endpoint, and the count of server-level log lines. Climbing connections mean the persistent-connection leak; an unchanged p99 means the slow endpoint was not actually waiting; a log count of zero means LOGGING is still disabling the server's loggers.
Pitfalls & edge cases¶
- Keeping
CONN_MAX_AGEfrom the WSGI config. Measured: PostgreSQL ran out of connections. - Silent server logs. Measured: 0 lines until
disable_existing_loggerswas set toFalse. - Expecting lifespan hooks. Django does not implement them; initialize lazily.
- Switching CPU-bound projects. Measured: no gain for fast requests.
Frequently Asked Questions¶
Should I run Django with uvicorn or gunicorn?
For views that mostly wait on I/O, run the ASGI app — gunicorn with uvicorn workers or plain uvicorn: in testing, slow requests ran at 1,603 req/s against 39 on four gunicorn sync workers. For fast CPU-light views, gunicorn sync workers performed as well or better.
Why does uvicorn say 'ASGI lifespan protocol appears unsupported' for Django?
Django does not implement ASGI lifespan events. It is harmless; create async resources lazily on first use and put sync startup code in AppConfig.ready().
Why did uvicorn's logs disappear after I configured Django LOGGING?
dictConfig's disable_existing_loggers defaults to True and disables uvicorn's loggers, which exist before Django loads. Set it to False; in testing that restored 8 log lines that had been 0.
What database settings does Django need under ASGI?
CONN_MAX_AGE = 0 and, for PostgreSQL, Django's built-in psycopg pool. Persistent connections accumulate under ASGI and exhausted PostgreSQL in testing.
Related¶
- Django Async — up to the topic overview.
- Streaming responses from async Django views — the server choice decides how streams behave.
- Network I/O & Protocol Handling — the section overview.