Django Advanced #1: Async views and ASGI
If you finished Django Basics (7 posts) plus Intermediate (7 posts), now you step into Advanced. The seven Advanced posts cover the topics you hit in large/long-running Django projects — async, commands, query optimization, caching, signals in depth, WebSocket, and deployment security.
- #1 Async views and ASGI ← this post
- #2 Custom management commands
- #3 Query optimization
- #4 Caching
- #5 Signals in depth and post-transaction work
- #6 Django Channels — WebSocket
- #7 Deployment security
On top of the views from Basics #4 FBV and Intermediate #1 CBV, we add async def. Django started supporting async at the ORM level from 4.1, and the story matured in 5.x.
WSGI vs ASGI #
Django lived on WSGI (Web Server Gateway Interface) for a long time. One synchronous function call handles one request. Concurrency comes from running more worker processes/threads.
ASGI (Asynchronous Server Gateway Interface) is the successor standard. It can handle async functions and long-lived connections like WebSocket / HTTP/2.
| WSGI | ASGI | |
|---|---|---|
| Function shape | def app(environ, start_response) | async def app(scope, receive, send) |
| Async | Not possible | Native |
| Protocol | HTTP only | HTTP, WebSocket, HTTP/2 |
| Server | gunicorn, uWSGI | uvicorn, daphne, hypercorn |
| Concurrency model | Process/thread | Event loop + coroutine |
Django supports both. Both wsgi.py and asgi.py entry points coexist.
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
application = get_asgi_application()If you serve through the ASGI entry point, both sync and async views work side by side. So migration can be incremental.
Your first async view #
import asyncio
from django.http import JsonResponse
async def hello(request):
await asyncio.sleep(0.1)
return JsonResponse({"hello": "async"})The only change is def becoming async def. Routing is the same.
from django.urls import path
from . import views
urlpatterns = [
path("hello/", views.hello),
]When Django calls a view, it auto-detects async with inspect.iscoroutinefunction and runs it on the event loop if so.
When does it pay off? #
Async is not free. For a single ORM call plus one response, a sync view is simpler and faster. Async pays off when:
- Calling several external HTTP APIs concurrently
- Long-lived connections like WebSocket (#6)
- External IO dominates the view
- You need to handle many concurrent connections in the same process
For pure CPU work or simple CRUD, switching to async barely helps. Pick the right tool for the job.
Async ORM — the a series
#
Every Django ORM query method has an a-prefixed async version.
| Sync | Async |
|---|---|
Post.objects.get(id=1) | await Post.objects.aget(id=1) |
post.save() | await post.asave() |
post.delete() | await post.adelete() |
Post.objects.create(...) | await Post.objects.acreate(...) |
Post.objects.filter(...).update(...) | await Post.objects.filter(...).aupdate(...) |
for p in Post.objects.all() | async for p in Post.objects.all() |
list(qs) | [p async for p in qs] |
from django.http import JsonResponse
from .models import Post
async def post_list(request):
posts = [
{"id": p.id, "title": p.title}
async for p in Post.objects.filter(published=True)[:20]
]
return JsonResponse({"posts": posts})
async def post_detail(request, pk):
post = await Post.objects.aget(pk=pk)
return JsonResponse({"id": post.id, "title": post.title})async for and async comprehensions are the core patterns.
Warning — relation access is still sync #
async def post_detail(request, pk):
post = await Post.objects.aget(pk=pk)
author_name = post.author.name # SynchronousOnlyOperationLazy loading of foreign keys on ORM objects is sync code. Calling it directly inside an async context raises SynchronousOnlyOperation.
Fix:
async def post_detail(request, pk):
post = await Post.objects.select_related("author").aget(pk=pk)
return JsonResponse({"id": post.id, "author": post.author.name})Pre-JOIN so no lazy load happens. The select_related / prefetch_related from #3 are essentially required in async.
Concurrent external API calls — async at its best #
This is where an async view shines. Let’s call three external services concurrently.
pip install httpximport asyncio
import httpx
from django.http import JsonResponse
async def dashboard(request):
async with httpx.AsyncClient(timeout=5.0) as client:
weather, news, stocks = await asyncio.gather(
client.get("https://api.weather.example/now"),
client.get("https://api.news.example/top"),
client.get("https://api.stocks.example/quote/AAPL"),
)
return JsonResponse({
"weather": weather.json(),
"news": news.json(),
"stocks": stocks.json(),
})The three calls finish in the time of the slowest one, not the sum. With a sync view + requests, you’d pay the sum of all three.
sync_to_async / async_to_sync #
Django’s async adapters. They live in asgiref.sync.
sync_to_async — call sync code from async
#
from asgiref.sync import sync_to_async
from .legacy import compute_report # sync function
async def report(request):
data = await sync_to_async(compute_report)(user_id=request.user.id)
return JsonResponse(data)By default, it runs in a separate thread, so it doesn’t block the event loop. The ORM’s a methods use this internally too.
@sync_to_async(thread_sensitive=True)
def db_work():
...thread_sensitive=True runs always on the same thread — needed for code with thread-local state, such as sync ORM transactions. The default is True.
async_to_sync — call async code from sync
#
from asgiref.sync import async_to_sync
def my_sync_view(request):
result = async_to_sync(fetch_data_async)(request.GET["q"])
return JsonResponse(result)Each call creates and tears down a fresh event loop, so calling it frequently is expensive. Use only when truly needed.
Async middleware #
Middleware can be async too.
class TimingMiddleware:
async_capable = True
sync_capable = False
def __init__(self, get_response):
self.get_response = get_response
async def __call__(self, request):
import time
start = time.perf_counter()
response = await self.get_response(request)
elapsed = (time.perf_counter() - start) * 1000
response["X-Elapsed-Ms"] = f"{elapsed:.1f}"
return responseTwo flags:
async_capable = True— works in async contextsync_capable = False— does not support sync
If both are True, Django automatically calls it in the right mode. But every sync/async boundary brings in an adapter and adds cost. Pick one mode if you can.
ASGI servers #
To serve async views, you need an ASGI server. Three are standard.
| uvicorn | daphne | hypercorn | |
|---|---|---|---|
| Maintainer | encode | django | pgjones |
| HTTP/2 | Supported | Not supported | Supported |
| WebSocket | Supported | Channels standard | Supported |
| Speed | Fastest | Average | Average |
| Recommended case | General ASGI | Channels (#6) | When HTTP/2 is needed |
uvicorn #
pip install "uvicorn[standard]"
uvicorn myproject.asgi:application --host 0.0.0.0 --port 8000 --workers 4In production, gunicorn + uvicorn worker is a common combo.
pip install gunicorn
gunicorn myproject.asgi:application \
-k uvicorn.workers.UvicornWorker \
--workers 4 --bind 0.0.0.0:8000gunicorn handles process management (restart, signals, logging), uvicorn handles the actual ASGI work.
daphne #
pip install daphne
daphne -b 0.0.0.0 -p 8000 myproject.asgi:applicationThe standard server for Channels (#6).
Coexistence of sync and async views #
You can mix both in the same project. Django auto-detects.
urlpatterns = [
path("classic/", views.classic_view), # def
path("modern/", views.modern_view), # async def
]If you serve via an ASGI server, both work fine. Sync views run on a thread pool (so they don’t block the event loop).
If you serve via a WSGI server (gunicorn standard worker, uWSGI), each async view runs through async_to_sync per request — that’s a significant performance penalty. If you have any async view, use an ASGI server.
Compared with FastAPI #
FastAPI from the Modern Python FastAPI series is the frequent comparison.
| Django (async) | FastAPI | |
|---|---|---|
| Starting point | Full-stack (admin, ORM, auth all in) | Micro |
| Async | Partial/mixed (parts of ORM still sync) | Async from the start |
| Type hints | Auxiliary | Core behavior |
| OpenAPI | DRF + separate | Built-in |
| Learning curve | Steep | Flat |
| Use case | Full sites, admin-heavy | Pure API |
Brand new projects written from scratch as fully async Django are rare. The usual pattern is adding async to a few views in an existing sync Django app (external APIs, WebSocket). For a 100% async API from day one, FastAPI is a better fit.
Common pitfalls #
1) SynchronousOnlyOperation
#
Happens when you make a sync ORM call inside an async view. Wrap in the a method or sync_to_async.
async def my_view(request):
post = Post.objects.get(pk=1) # sync callasync def my_view(request):
post = await Post.objects.aget(pk=1)2) Lazy relation access #
The one we just saw. Pre-fetch with select_related/prefetch_related.
3) ORM transactions are still sync #
The @transaction.atomic decorator only works on sync functions. If you need a transaction inside an async view, the pattern is to wrap the work in a sync function and call it via sync_to_async.
from asgiref.sync import sync_to_async
from django.db import transaction
@sync_to_async
def create_with_tx(data):
with transaction.atomic():
post = Post.objects.create(**data)
Tag.objects.create(post=post, name=data["tag"])
return post
async def my_view(request):
post = await create_with_tx({"title": "...", "tag": "..."})Django 6.x is expected to officially support async transactions. Until then, this pattern is recommended.
4) Adapter cost at middleware boundaries #
If a request crosses sync→async boundaries multiple times, an adapter (sync_to_async/async_to_sync) jumps in each time and costs add up. Keep the middleware chain in one mode if you can.
Wrap-up #
What you took home this time:
- WSGI is sync; ASGI is async + WebSocket/HTTP2
async def view(request)— proper support from 4.1- ORM’s
amethods —aget,acreate,aupdate,async for - Relation access is still sync — pre-fetch with
select_related - Concurrent external API calls (
asyncio.gather) are async’s real value - Cross boundaries with
sync_to_async,async_to_sync - async middleware —
async_capable/sync_capableflags - ASGI servers — uvicorn (general), daphne (Channels), hypercorn (HTTP/2)
- Sync/async views can coexist; serve via an ASGI server
- Async pays off where external IO dominates; simple CRUD is better off sync
@transaction.atomicis still sync — wrap withsync_to_async
In the next post (#2 Custom management commands) we cover another axis of operations — command-line work. How to make expired-token cleanup, batch stat computation, data migrations, and other cron jobs feel right at home in Django.