Django Advanced #1: Async views and ASGI

8 min read

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.

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.

WSGIASGI
Function shapedef app(environ, start_response)async def app(scope, receive, send)
AsyncNot possibleNative
ProtocolHTTP onlyHTTP, WebSocket, HTTP/2
Servergunicorn, uWSGIuvicorn, daphne, hypercorn
Concurrency modelProcess/threadEvent loop + coroutine

Django supports both. Both wsgi.py and asgi.py entry points coexist.

myproject/asgi.py
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 #

myapp/views.py
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.

myapp/urls.py
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.

SyncAsync
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]
ORM inside an async view
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 #

🚫 Lazy relation inside an async view
async def post_detail(request, pk):
    post = await Post.objects.aget(pk=pk)
    author_name = post.author.name   # SynchronousOnlyOperation

Lazy loading of foreign keys on ORM objects is sync code. Calling it directly inside an async context raises SynchronousOnlyOperation.

Fix:

✅ Pre-fetch with select_related
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.

Install
pip install httpx
Concurrent calls
import 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 #

Calling sync code in an async view
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.

thread_sensitive option
@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 #

Calling async code in a sync view
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.

async middleware
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 response

Two flags:

  • async_capable = True — works in async context
  • sync_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.

uvicorndaphnehypercorn
Maintainerencodedjangopgjones
HTTP/2SupportedNot supportedSupported
WebSocketSupportedChannels standardSupported
SpeedFastestAverageAverage
Recommended caseGeneral ASGIChannels (#6)When HTTP/2 is needed

uvicorn #

Install / run
pip install "uvicorn[standard]"

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

In production, gunicorn + uvicorn worker is a common combo.

gunicorn + uvicorn worker
pip install gunicorn

gunicorn myproject.asgi:application \
  -k uvicorn.workers.UvicornWorker \
  --workers 4 --bind 0.0.0.0:8000

gunicorn handles process management (restart, signals, logging), uvicorn handles the actual ASGI work.

daphne #

daphne
pip install daphne

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

The standard server for Channels (#6).

Coexistence of sync and async views #

You can mix both in the same project. Django auto-detects.

urls.py — mixed
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 pointFull-stack (admin, ORM, auth all in)Micro
AsyncPartial/mixed (parts of ORM still sync)Async from the start
Type hintsAuxiliaryCore behavior
OpenAPIDRF + separateBuilt-in
Learning curveSteepFlat
Use caseFull sites, admin-heavyPure 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 call
async 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.

Transaction inside an async view
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 a methods — 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_capable flags
  • 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.atomic is still sync — wrap with sync_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.

X