Django Advanced

Async Django and ASGI in Production: Async Views, the Async ORM, and When It Actually Helps

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.

DjangoZen Team Jul 11, 2026 15 min read 3 views

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.

WSGI, ASGI, and what async actually buys you

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.

Async views

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.

The async ORM

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.

Mixing sync and async safely

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.

Async middleware and the sync/async boundary

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

Serving ASGI in production

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.

Pitfalls that bite in production

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.

Streaming and long-lived connections

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.

Async views are not a task queue

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.

Databases, connections, and pooling

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.

Testing async code

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.

Timeouts and cancellation

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.

Observability across await points

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.

When to reach for async

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.