Backend / Web frameworks / Django / 13_async_django_channels.md

Async Django, Channels, and Async ORM

Updated 6 interview angles 6 min read source
On this page11
  1. Async views
  2. Async ORM (Django 4.1+)
  3. sync_to_async / async_to_sync
  4. Channels — WebSockets and beyond
  5. Channel layers — Redis as the inter-process bus
  6. Running async Django in production
  7. Middleware
  8. When does async Django win
  9. When to use FastAPI instead
  10. Common gotchas
  11. Interview angle

Async Django, Channels, and Async ORM

Django got serious about async between 3.1 (2020) and 4.2 / 5.x. You can write async def views, call the ORM with await, and run WebSockets via Channels — but the migration story has rough edges worth knowing.

Async views

python
# views.py
import httpx
from django.http import JsonResponse

async def fetch_external(request):
    async with httpx.AsyncClient(timeout=5) as c:
        r = await c.get("https://api.example.com/data")
    return JsonResponse(r.json())

The view is async def; Django runs it through ASGI. WSGI servers (gunicorn + sync workers) can still serve async views by running the loop per-request — but you lose the concurrency benefit. Use ASGI (uvicorn / daphne / hypercorn) in production for async-heavy apps.

Async ORM (Django 4.1+)

python
from django.contrib.auth import get_user_model
User = get_user_model()

async def view(request):
    user = await User.objects.aget(id=request.user.id)            # async get
    orders = [o async for o in user.order_set.all()]              # async iteration
    count = await user.order_set.acount()                          # async count
    await user.asave()                                             # async save

Async methods are prefixed with aaget, acreate, asave, adelete, acount, afirst, etc. Iteration over QuerySets uses async for.

Critical gotcha: the ORM is async at the API level, but the database driver is still synchronous underneath. Django wraps blocking driver calls in sync_to_async. You get the programming model of async but not the concurrency benefit — each query still blocks a thread.

For real async DB I/O, use psycopg (3.x) — its async support pairs with a properly-async driver. Django 5.x is moving toward this; check current state before relying on it for perf.

sync_to_async / async_to_sync

The bridge between sync and async Django code:

python
from asgiref.sync import sync_to_async, async_to_sync

async def my_async_view(request):
    # Call sync function from async context
    result = await sync_to_async(slow_sync_function)(arg)

# Call async function from sync context
def my_sync_view(request):
    result = async_to_sync(my_async_function)(arg)

sync_to_async runs the sync function in a thread pool. async_to_sync runs the async function in its own loop. Both add overhead — use sparingly.

Thread sensitivity: Django ORM and DB connections are thread-local. By default sync_to_async runs in a thread pool, so the connection might be on a different thread than expected. Use sync_to_async(fn, thread_sensitive=True) to force serial execution in the main thread (slower, but safer for ORM operations that depend on transaction state).

Channels — WebSockets and beyond

Django Channels extends Django to handle WebSockets, long-polling, server-sent events, and any non-HTTP protocol — anything ASGI can route.

python
# routing.py
from channels.routing import ProtocolTypeRouter, URLRouter
from django.urls import path
from chat.consumers import ChatConsumer

application = ProtocolTypeRouter({
    "http": django_asgi_app,
    "websocket": URLRouter([
        path("ws/chat/<room>/", ChatConsumer.as_asgi()),
    ]),
})
python
# consumers.py
from channels.generic.websocket import AsyncWebsocketConsumer
import json

class ChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.room = self.scope["url_route"]["kwargs"]["room"]
        await self.channel_layer.group_add(self.room, self.channel_name)
        await self.accept()

    async def disconnect(self, code):
        await self.channel_layer.group_discard(self.room, self.channel_name)

    async def receive(self, text_data=None, bytes_data=None):
        data = json.loads(text_data)
        await self.channel_layer.group_send(self.room, {
            "type": "chat.message",
            "message": data["message"],
            "user": self.scope["user"].username if self.scope["user"].is_authenticated else "anon",
        })

    async def chat_message(self, event):
        await self.send(text_data=json.dumps(event))

Channel layers — Redis as the inter-process bus

Channels by default runs in a single process. To broadcast across many workers (typical production), use a channel layer — usually Redis.

python
# settings.py
CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {"hosts": [("redis", 6379)]},
    },
}

channel_layer.group_send(...) publishes to Redis; every worker subscribed to the group receives it and forwards to its connected WebSockets. Standard fan-out pattern.

Gotcha: Redis is the SPOF and the bottleneck. At high WebSocket fan-out (10k+ connections), the channel layer cost dominates — consider running fewer workers with more connections each, or a Redis cluster.

Running async Django in production

ASGI server is the change:

bash
# Old (sync only)
gunicorn myproject.wsgi:application

# New (async + sync)
uvicorn myproject.asgi:application --workers 4
# or with gunicorn driving uvicorn workers:
gunicorn myproject.asgi:application -k uvicorn.workers.UvicornWorker --workers 4

asgi.py is generated by django-admin startproject since 3.0. It uses get_asgi_application().

Each worker runs its own event loop. Use multiple workers to use multiple CPU cores (one event loop = one core). Within a worker, async I/O lets you handle many concurrent requests.

Middleware

Django middleware can be sync, async, or both. Modern pattern:

python
class MyMiddleware:
    sync_capable = True
    async_capable = True

    def __init__(self, get_response):
        self.get_response = get_response
        self._is_coroutine = iscoroutinefunction(get_response)

    def __call__(self, request):
        if self._is_coroutine:
            return self._async_call(request)
        return self._sync_call(request)

    async def _async_call(self, request):
        # do async pre-processing
        response = await self.get_response(request)
        # do async post-processing
        return response

    def _sync_call(self, request):
        ...

Most third-party middleware is sync-only; Django wraps it via sync_to_async. Performance suffers if you have a long middleware chain in an async app.

When does async Django win

  • WebSockets / SSE / long-lived connections — Channels, no alternative in Django land.
  • Heavy outbound HTTP / API aggregation — async http calls within a request actually parallel.
  • Many slow external dependencies per requestasyncio.gather over awaits beats sync sequential.

When async Django mostly doesn’t pay off (yet):

  • DB-bound services — async ORM thread-wraps the sync driver; no real concurrency gain.
  • Existing big sync codebases — sync_to_async tax everywhere; not worth it for a marginal win.

When to use FastAPI instead

For greenfield async-first services with no Django admin / ORM / forms requirement, FastAPI is usually faster to build and runs natively async end-to-end (Starlette + asyncpg/SQLAlchemy 2.0 async). Django’s strength is everything around the ORM and admin; if you don’t need those, FastAPI is leaner.

Common gotchas

  • Calling sync ORM from async view. User.objects.get(...) (no a) in an async view raises SynchronousOnlyOperation. Use aget.
  • Querysets in async iteration. for u in User.objects.all() is sync; use async for u in User.objects.all().
  • Middleware order matters more. A sync middleware in an async chain forces a thread switch.
  • Long-lived connections + ORM. WebSocket consumer holding an open DB connection across awaits — connection state is fragile. Use database_sync_to_async (channels helper) to run DB calls in a thread with the right connection lifecycle.
  • Transactions across await. Django transactions are tied to a thread; async with transaction.aatomic(): is new (5.x) and the right way. Don’t await inside a sync transaction.atomic() block.

Interview angle 6

  • “How do you write an async view in Django?”async def view(request): plus async ORM methods (aget, acreate, acount). Run under ASGI (uvicorn / daphne). WSGI works but loses concurrency.
  • “Is Django’s async ORM actually async?” — at the API level, yes. Underneath, the DB driver is still sync — Django wraps blocking calls in sync_to_async running on a thread pool. You get the programming model but limited concurrency gain on DB-bound work. True async DB requires psycopg 3 + matching driver support.
  • “What’s sync_to_async?” — runs a sync function in a thread pool from async code. Use thread_sensitive=True for ORM calls to keep thread-local DB state consistent. There’s a measurable overhead — don’t sprinkle it everywhere.
  • “How does Channels work for WebSockets?” — WebSockets routed by ProtocolTypeRouter to a Consumer (AsyncWebsocketConsumer). For multi-worker fan-out, configure a channel layer (Redis); group_send publishes via Redis to all workers subscribed.
  • “How do you run async Django in production?” — ASGI server (uvicorn / daphne / hypercorn), often via gunicorn with uvicorn worker class. Multiple workers for multi-core; each worker has its own event loop.
  • “When wouldn’t you use async Django?” — DB-bound apps where async ORM doesn’t help (sync driver wrapped in threads), or large legacy sync codebases where sync_to_async tax outweighs gains. For greenfield async-first work without needing the Django ecosystem, FastAPI is often a better fit.