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
| Layer | Status |
|---|---|
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 |
| Channels | Separate 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 qsactually 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.authortriggers a sync query inside an async view, which raisesSynchronousOnlyOperation. Useselect_related/prefetch_relatedaggressively, orawait 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 offif you don’t use lifespan events — quiets a startup warning.--limit-concurrency 1000to 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
StreamingResponseisn’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:
- Move to ASGI first. Stay on sync views. Confirm no regressions. This alone unlocks Channels and middleware concurrency.
- Convert hot endpoints that do parallel I/O. Measure. Most won’t gain much; a few will go 3–5× faster.
- Convert middleware as you see thread-pool overhead in traces.
- 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
- Django vs FastAPI — opinionated comparison.
- Deploying Django to Production — the deployment basics.
- Django ORM Deep Dive — sync ORM patterns that still apply.
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 .