Skip to content

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

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.

The same Django views under four server setups A grid of 4 rows by 4 columns. The same Django views under four server setups server hello-world req/s one-row aget req/s 100 ms wait, 200 concurrent uvicorn, 1 worker 1,815-1,913 1,018 1,546 req/s uvicorn, 4 workers 4,262-4,498 1,920 1,603 req/s gunicorn sync, 4 workers (WSGI) 4,497-4,962 2,420 39 req/s, p50 5.1 s gunicorn + UvicornWorker, 4 workers 6,049-6,906 3,350 1,603 req/s Django 6.1.1, Python 3.14, PostgreSQL 17; load generated by aiohttp on the same host, so fast-path figures are approximate.

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.

How Django's LOGGING switched off uvicorn's loggers A flow of 4 stages. How Django's LOGGING switched off uvicorn's loggers uvicorn starts configures uvicorn.* loggers imports project.asgi django.setup() dictConfig(LOGGING) disable_existing_loggers=True uvicorn.* disabled 0 log lines Set disable_existing_loggers to False; measured 8 lines instead of 0.

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.

Which server setup fits this Django project? A decision on What do most requests spend their time on with 4 outcomes. Which server setup fits this Django project? What do most requests spend their time on? waiting on I/O gunicorn + UvicornWorker 1,603 vs 39 req/s fast CPU work gunicorn sync is fine 4,962 req/s hello-world a few slow endpoints split them onto ASGI no head-of-line blocking any ASGI switch pool + CONN_MAX_AGE=0, keep loggers before go-live ASGI pays for waiting; configure the database and logging before it ships.

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_loggers is False, and server logs appear.
  • CONN_MAX_AGE is 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_AGE from the WSGI config. Measured: PostgreSQL ran out of connections.
  • Silent server logs. Measured: 0 lines until disable_existing_loggers was set to False.
  • 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.