Async Django done right: the ASGI model, async views and the async ORM, safely mixing sync and async with sync_to_async, serving under Uvicorn, and the decision of when concurrency actually pays off.
Async support in Django is real and production-ready, but it is not a free speedup you sprinkle on a slow view. Used well, it lets a single worker handle many concurrent I/O-bound requests — external API calls, slow databases, streaming responses — without a thread per request. Used carelessly, it makes code slower, deadlocks under load, or corrupts data. This tutorial covers the ASGI model, async views and the async ORM, safely mixing sync and async, and the decision of when async is worth it at all.
The classic Django deployment runs under WSGI: a synchronous protocol where each request occupies one worker (a process or thread) for its entire lifetime. If that request spends 300ms waiting on an external API, the worker is idle but unavailable — you scale concurrency by adding workers, and each worker costs memory. ASGI is the asynchronous successor: a single event-loop worker can suspend a request that is waiting on I/O and serve others in the meantime. The win is concurrency under I/O wait, not raw CPU speed. Async does nothing for CPU-bound work — a view that computes for 200ms blocks the whole event loop for 200ms, starving every other request on that worker.
The practical rule: async pays off when your views spend most of their time waiting — calling third-party APIs, hitting slow upstream services, holding open long-lived connections (SSE, WebSockets). If your views are cheap and DB-bound with a fast local database, plain WSGI with a sensible worker count is simpler and usually just as fast.
An async view is just a coroutine. Django detects the async def and runs it on the event loop under an ASGI server; under WSGI it still works but Django wraps it in its own event loop per request, losing the benefit.
import httpx
from django.http import JsonResponse
async def dashboard(request):
async with httpx.AsyncClient() as client:
# These two upstream calls run truly concurrently
prices, weather = await asyncio.gather(
client.get("https://api.example.com/prices"),
client.get("https://api.example.com/weather"),
)
return JsonResponse({"prices": prices.json(), "weather": weather.json()})
The asyncio.gather here is the whole point: two 200ms calls complete in ~200ms total instead of 400ms. Fan-out to independent I/O is where async shines, and it is impossible to express cleanly in synchronous code without threads.
Since Django 4.1 the ORM exposes async query methods with an a prefix, and querysets support async for. You must use these inside async views — calling a synchronous ORM method directly from a coroutine raises SynchronousOnlyOperation.
async def article_list(request):
articles = []
async for article in Article.objects.filter(published=True):
articles.append(article.title)
count = await Article.objects.filter(published=True).acount()
latest = await Article.objects.afirst()
obj, created = await Article.objects.aget_or_create(slug="intro")
return JsonResponse({"titles": articles, "count": count})
The async methods (acount, aget, afirst, acreate, aget_or_create, aupdate, adelete) do not make the database itself asynchronous — under the hood Django still uses a synchronous database driver run in a thread pool. What you gain is that the event loop is not blocked while that query runs, so other requests proceed. Full async database drivers are on the roadmap but the thread-pool bridge is what ships today.
Real code is rarely all-async. You will call synchronous libraries from async views and occasionally async code from sync context. Django gives you two adapters: sync_to_async to call blocking code from a coroutine, and async_to_sync for the reverse.
from asgiref.sync import sync_to_async
async def profile(request):
# A third-party sync SDK, or ORM code you can't easily convert
result = await sync_to_async(legacy_sdk.fetch, thread_sensitive=True)(request.user.id)
return JsonResponse(result)
The thread_sensitive=True default matters: it forces the wrapped code to run in a single shared thread so that libraries relying on thread-local state — including Django's database connections and transactions — behave correctly. Setting thread_sensitive=False runs the call in a fresh thread-pool thread, which is faster for genuinely independent work but dangerous for anything touching the ORM inside a transaction. When in doubt, keep it True.
Middleware can be sync or async, and Django adapts between them automatically — but every adaptation has a cost. If you run async views behind a stack of synchronous middleware, Django wraps each boundary in a thread, eroding the benefit. Mark middleware async-capable so the request flows through the event loop end to end.
class TimingMiddleware:
async_capable = True
sync_capable = False
def __init__(self, get_response):
self.get_response = get_response
async def __call__(self, request):
start = time.monotonic()
response = await self.get_response(request)
response["X-Elapsed"] = f"{time.monotonic() - start:.3f}"
return response
You need an ASGI server. The common production setup is Gunicorn managing Uvicorn workers, which gives you Gunicorn's process management with Uvicorn's fast event loop.
gunicorn djzen.asgi:application \
-k uvicorn.workers.UvicornWorker \
--workers 4 --bind 0.0.0.0:8000
Note that async concurrency and worker count are now two different dials. Each Uvicorn worker runs one event loop that handles many concurrent requests, so you need far fewer workers than the WSGI rule of "2×CPU + 1". But you still want several workers to use multiple cores and to survive a worker restart without dropping all traffic.
The most common failure is a hidden blocking call in an async view — a synchronous requests.get, a time.sleep, a CPU-heavy loop, or a sync library call not wrapped in sync_to_async. Any of these blocks the entire event loop, so one slow request stalls every other request on that worker. Under load this looks like sudden latency cliffs that are hard to trace. Audit async views for anything that does not await.
The second is database connections. Django opens a connection per thread; with async and thread pools you can create far more connections than you expect, exhausting Postgres. Put PgBouncer in front and cap CONN_MAX_AGE deliberately. The third is unbounded concurrency: asyncio.gather over a large list fires every call at once and can hammer an upstream into rate limits — bound it with an asyncio.Semaphore.
Async's other natural home is responses that stay open. Server-Sent Events, long polling, and streaming AI tokens all hold a connection while data trickles out, and under WSGI each one pins a whole worker for the duration. Under ASGI an async generator streams without blocking the loop, so one worker can hold thousands of open streams.
from django.http import StreamingHttpResponse
async def events(request):
async def stream():
async for item in watch_updates():
yield f"data: {item}\n\n"
return StreamingHttpResponse(stream(), content_type="text/event-stream")
Full bidirectional WebSockets go a step further and live in Django Channels, which is built on the same ASGI foundation. If you find yourself reaching for streaming and sockets, ASGI is no longer optional — it is the only model that serves them efficiently.
A frequent misuse is treating an async view as a place to run background work — kicking off a long job and hoping it finishes after the response is sent. It will not reliably: once the response is returned the request scope can be torn down, and any unawaited work is at the mercy of the event loop's lifecycle. Async concurrency is for work the request genuinely waits on and returns. Fire-and-forget work that outlives the request belongs in Celery or a similar queue, where it has its own worker, retries, and durability. Keep the two ideas separate and you avoid a whole class of "the job sometimes just doesn't run" bugs.
Async magnifies a problem WSGI mostly hid: connection count. Django opens one database connection per thread, and the async stack multiplies threads through its pools, so a handful of Uvicorn workers can open far more Postgres connections than you planned, hitting max_connections and refusing service. The fix is a connection pooler in front of the database — PgBouncer in transaction mode — so hundreds of app-side connections multiplex onto a small pool of real ones. Set CONN_MAX_AGE deliberately and load-test the connection count, because it fails suddenly and only under real concurrency.
Async views need async tests. Django's test client has an async variant, and pytest-asyncio lets you write coroutine tests directly. The important discipline is to test the concurrency you rely on — assert that two upstream calls actually overlap, and that a slow dependency does not serialize requests — because a blocking call sneaks back in easily and silently undoes the entire benefit.
import pytest
@pytest.mark.asyncio
async def test_dashboard_concurrent(async_client):
resp = await async_client.get("/dashboard/")
assert resp.status_code == 200
Treat "no accidental blocking call" as an invariant worth a test, not a thing you check once by hand.
An async view that awaits an upstream service must bound the wait — without a timeout, one hung dependency holds a connection open until the client or server gives up, and enough of them exhaust the loop. Wrap external calls in asyncio.timeout and decide the fallback explicitly: a cached value, a partial response, or a clean error.
try:
async with asyncio.timeout(2.0):
data = await client.get(url)
except TimeoutError:
data = cached_fallback()
Cancellation is the flip side. When a client disconnects, the request's task is cancelled and any child tasks you spawned should be too — leaking orphaned tasks is a slow memory leak that only shows under sustained traffic. Structure concurrent work with task groups so cancellation propagates cleanly rather than leaving work running with nowhere to return.
Debugging async is harder because a single request hops threads and suspends at every await, so naive thread-local logging loses the request's identity. Use contextvars to carry a request id across await boundaries, and instrument with OpenTelemetry, which understands async context and stitches the spans of concurrent upstream calls into one coherent trace. Without that, a slow async request is a puzzle; with it, you can see exactly which awaited call cost the latency.
Reach for async when a view is dominated by concurrent, independent I/O — aggregating several external APIs, proxying slow upstreams, streaming server-sent events, or holding many long-lived connections. Stay on WSGI when your workload is CPU-bound, when it is simple DB-bound CRUD against a fast local database, or when the team is not ready to reason about event loops and the sync/async boundary. Async is a powerful tool with a real complexity tax; adopt it where the concurrency win is concrete, not because it sounds modern.