Django’s async story used to be embarrassing. By Django 5.x in 2026, it’s solid: async views, async ORM, async middleware, async tests. Not as polished as FastAPI from a green field, but if you’re already on Django, you don’t have to leave to get most of the benefits.

This post is the practical view. What works, what’s still rough, and what to actually use.

What’s async in Django 5.x

LayerStatus
Views (async def)✅ Stable since 4.1
ORM (Model.objects.aget, aupdate, aiterator)✅ Mature in 5.x
Middleware (sync + async)
Forms / class-based views⚠️ Async support partial; FBVs cleanest
Templates✅ async-compatible
Auth / sessions
Admin❌ sync — but it doesn’t matter, admin is internal
ChannelsSeparate package, still the path for WebSockets

In practice: async views + async ORM cover ~95% of what you’d want in a request/response API. The rest is fine sync.

When async pays for itself

Async helps you when:

  • A view does multiple I/O calls that can run concurrently (DB + cache + external API).
  • A view calls slow external APIs that don’t pin Python’s GIL but still cost wall time.
  • You’re building streaming / SSE / WebSocket endpoints.
  • You’re running on ASGI workers that can multiplex many in-flight requests per process.

Async does not help when:

  • Your view is one DB query and a render. Sync is just as fast and simpler.
  • Your bottleneck is CPU (image processing, ML inference). Use threads or processes.
  • You’re stuck on a sync deployment (mod_wsgi, gunicorn-sync). Migrating to ASGI is the real win.

Async views

# views.py
from django.http import JsonResponse
from .models import Article


async def articles(request):
    qs = Article.objects.filter(published=True).order_by("-created")[:20]
    items = [a async for a in qs]   # async iterator
    return JsonResponse({"items": [{"id": a.id, "title": a.title} for a in items]})

Two important changes from sync:

  • qs[:20] is a queryset; async for a in qs actually executes it asynchronously.
  • Use await Model.objects.aget(...) instead of .get() inside async views. Calling sync ORM in async will raise.

The async ORM

# Reads
user = await User.objects.aget(pk=42)
exists = await User.objects.filter(email=e).aexists()
count = await User.objects.acount()

# Writes
await user.asave()
await user.adelete()
await User.objects.aupdate_or_create(email=e, defaults={...})

# Iteration
async for u in User.objects.filter(active=True):
    ...

Coverage in 5.x is broad. The sharp edges:

  • No async transactions yet in vanilla Django. Use sync_to_async(transaction.atomic, thread_sensitive=True)(...) if you need explicit transactions inside async, or wait for Django 6.
  • Related-object access is sync. obj.author triggers a sync query inside an async view, which raises SynchronousOnlyOperation. Use select_related/prefetch_related aggressively, or await obj.aauthor (where supported).
# Wrong — synchronous attribute access raises in async context
async def bad(request, post_id):
    post = await Post.objects.aget(pk=post_id)
    return JsonResponse({"author": post.author.name})    # 💥 SynchronousOnlyOperation

# Right — fetch the relation up front
async def good(request, post_id):
    post = await Post.objects.select_related("author").aget(pk=post_id)
    return JsonResponse({"author": post.author.name})

Concurrency inside a view

import asyncio

async def dashboard(request):
    user, articles, weather = await asyncio.gather(
        User.objects.aget(pk=request.user.id),
        sync_to_async(list)(Article.objects.filter(featured=True)[:5]),
        fetch_weather_async(),
    )
    return JsonResponse({"user": ..., "articles": ..., "weather": ...})

This is the killer feature. Three I/O calls in parallel = ~max(t1, t2, t3) instead of t1+t2+t3. It only works under ASGI; under WSGI, your event loop has nowhere to live.

sync_to_async and async_to_sync — the bridge

You will need them. Old library is sync? Wrap it.

from asgiref.sync import sync_to_async, async_to_sync

# Calling sync code from async view
result = await sync_to_async(some_legacy_lib.compute, thread_sensitive=True)(arg)

# Calling async code from sync view (e.g., Celery task)
async_to_sync(send_telegram_message)(chat_id, "hi")

The thread_sensitive=True default means all calls go through one thread. That’s the safe choice when the wrapped code touches DB connections (Django assumes thread affinity for connections). Set False when you’ve audited the code.

Middleware

Mix sync and async middleware freely; Django adapts. To keep an async view fully async, write your middleware async:

from django.utils.deprecation import MiddlewareMixin

class TimingMiddleware(MiddlewareMixin):
    async_capable = True
    sync_capable = False

    async def __call__(self, request):
        start = time.perf_counter()
        response = await self.get_response(request)
        response["X-Duration-ms"] = f"{(time.perf_counter() - start) * 1000:.0f}"
        return response

If a single sync middleware sneaks into the chain, every request pays a thread-pool round trip. Audit the chain.

Deploying ASGI

uv add daphne uvicorn[standard]

Behind nginx, use Uvicorn with multiple workers:

uvicorn project.asgi:application --host 0.0.0.0 --port 8000 --workers 4

Or Daphne if you also serve Channels (WebSockets):

daphne -b 0.0.0.0 -p 8000 project.asgi:application

Both speak ASGI 3.0. A few production knobs:

  • --lifespan off if you don’t use lifespan events — quiets a startup warning.
  • --limit-concurrency 1000 to bound max in-flight requests per worker.
  • Run multiple workers; ASGI does not save you from the GIL inside one process.

Channels — when to use it

If your app is request/response, use plain Django + ASGI. Channels is the right tool when you have:

  • WebSockets (chat, notifications, collaborative editing)
  • Server-Sent Events (SSE) where ASGI’s plain StreamingResponse isn’t enough
  • Background tasks fanning out via a redis-backed worker pool
# routing.py
from channels.routing import URLRouter, ProtocolTypeRouter
from channels.auth import AuthMiddlewareStack
from django.urls import path

from .consumers import ChatConsumer

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

class ChatConsumer(AsyncJsonWebsocketConsumer):
    async def connect(self):
        self.room = self.scope["url_route"]["kwargs"]["room"]
        await self.channel_layer.group_add(f"chat-{self.room}", self.channel_name)
        await self.accept()

    async def disconnect(self, code):
        await self.channel_layer.group_discard(f"chat-{self.room}", self.channel_name)

    async def receive_json(self, content):
        await self.channel_layer.group_send(
            f"chat-{self.room}",
            {"type": "chat.msg", "msg": content["msg"], "from": str(self.scope["user"])},
        )

    async def chat_msg(self, event):
        await self.send_json(event)

The channel layer (Redis) is the magic — it lets you broadcast across processes and pods. Don’t try to roll your own; the back-pressure handling is hard.

Testing async

# pytest with pytest-django + asyncio
import pytest

@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
async def test_articles(async_client):
    resp = await async_client.get("/articles/")
    assert resp.status_code == 200
    assert "items" in resp.json()

Use transaction=True for tests that hit the async ORM — savepoints behave differently with the async pool.

Migration tips for existing Django apps

If you’re moving an existing Django service to async, do it gradually:

  1. Move to ASGI first. Stay on sync views. Confirm no regressions. This alone unlocks Channels and middleware concurrency.
  2. Convert hot endpoints that do parallel I/O. Measure. Most won’t gain much; a few will go 3–5× faster.
  3. Convert middleware as you see thread-pool overhead in traces.
  4. Don’t rewrite the whole app. The 80/20 of async benefit lives in a small fraction of routes.

A few things I wouldn’t bother with

  • Async forms. Forms are dominated by validation, not I/O. Sync is fine.
  • Async signals. Django signals are synchronous and shouldn’t fire 50 IO-bound handlers anyway. Use a real task queue.
  • Async Celery. Celery itself isn’t async-native; the workers are sync workers running async code via asgiref. If you need true async background work, look at arq, dramatiq, or taskiq.

When I’d just use FastAPI instead

For a brand-new async API:

  • Heavy LLM/RAG I/O patterns
  • WebSockets-first or streaming-first
  • No need for the admin, auth, ORM bundle Django gives you
  • Team is comfortable wiring up auth/ORM themselves

…I’d reach for FastAPI. See FastAPI + Pydantic v2 + SQLAlchemy 2.0 — Production Patterns .

For everything else — admin, batteries-included views, mature auth, complex relational schemas — Django 5 async is more than enough.

Read this next

If you want a Django 5 ASGI starter template wired up with async views, the async ORM, and a sane deploy story, it’s on rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .