Writing Async Views in Django¶
Django has supported async def views since 3.1, and in Django 6.1 they are an ordinary production option rather than an experiment. The benefit is specific: a view that spends most of its time waiting — on an upstream API, a slow query, a websocket push — can wait without holding a worker. The costs are less obvious, and some popular beliefs about them are out of date. Measured with Django 6.1.1 on Python 3.14, uvicorn 0.54 and one worker: 1,000 requests at 200 concurrent to a view that awaited asyncio.sleep(0.1) completed at 1,546 requests per second with a p50 of 106 ms; the same load against four gunicorn sync workers managed 39 requests per second with a p50 of 5.1 s. A sync view that called time.sleep(0.1) under the same ASGI server also ran at 1,411 requests per second — Django runs each request's sync code in its own thread, so sync views are not serialized under ASGI. For CPU-light hello-world views the async version was no faster: 1,385–1,913 requests per second against 1,585–1,815 for sync, within run-to-run noise. This guide writes async views where they pay and keeps them correct.
Prerequisites¶
- Django 5.0+ (tested on 6.1.1),
pip install django uvicorn httpx. - An ASGI server, from running Django under ASGI with uvicorn.
- The topic overview, Django Async.
1. Write the view as a coroutine¶
An async view is an async def that takes the request and returns a response. Django detects it and awaits it directly when running under ASGI:
import httpx
from django.http import JsonResponse
client = httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=2.0)) # module-level, reused
async def weather(request, city: str):
resp = await client.get("https://api.example.com/weather", params={"q": city})
resp.raise_for_status()
return JsonResponse(resp.json())
Under ASGI the request is handled on the event loop; while the upstream call is in flight, the worker serves other requests. A shared, module-level httpx.AsyncClient reuses connections across requests — creating one per request would pay TCP and TLS setup every time, as measured in reusing aiohttp ClientSession across requests. Django has no ASGI lifespan support (uvicorn logs ASGI 'lifespan' protocol appears unsupported.), so a module-level client is created lazily on first use and never explicitly closed; that is acceptable for a process-lifetime client.
Verify: a load test with many concurrent slow-upstream requests completes in about one upstream latency per batch, not one per request.
2. Know what sync views cost under ASGI¶
A common claim is that sync views under ASGI are funnelled through one thread and serialize. Measured on Django 6.1, they are not:
def slow_sync(request):
time.sleep(0.1) # stands in for a blocking client call
return HttpResponse("ok")
1,000 requests at 200 concurrent completed at 1,411 requests per second, nearly as fast as the async version. Django wraps each request in its own ThreadSensitiveContext, so thread-sensitive sync work for one request runs on a thread dedicated to that request — the thread names in the test were ThreadPoolExecutor-201_0, ThreadPoolExecutor-202_0 and so on, one executor per request. That makes sync views workable under ASGI, but not free: each concurrent sync request holds a thread (and its stack memory), and anything thread-local — including database connections — is per request. The memory side of threads versus tasks is measured in comparing memory per task, thread and process.
Verify: under your peak concurrency, the process's thread count and RSS stay within limits when the slow paths are sync views.
3. Fan out inside one request¶
Async views make concurrent upstream calls within a request straightforward — the pattern that is awkward in sync Django:
import asyncio
async def dashboard(request, user_id: int):
async with asyncio.TaskGroup() as tg:
profile = tg.create_task(client.get(f"{USERS}/users/{user_id}"))
orders = tg.create_task(client.get(f"{ORDERS}/users/{user_id}/orders"))
recs = tg.create_task(client.get(f"{RECS}/users/{user_id}"))
return JsonResponse({
"profile": profile.result().json(),
"orders": orders.result().json(),
"recommendations": recs.result().json(),
})
The request takes as long as the slowest call rather than the sum of all three, and if one fails the TaskGroup cancels the others instead of letting them finish uselessly. Add per-call timeouts so one slow dependency cannot hold the whole page, as in adding timeouts and fallbacks to degraded dependencies, and decide per dependency whether a failure should fail the page or degrade it.
Verify: with one upstream delayed by a second, the view's latency rises by about a second, not by the sum of the delays.
4. Do not block the loop from an async view¶
Inside an async view, every blocking call stops every other request on the worker. The common culprits are the ORM's synchronous methods, requests, time.sleep, file I/O and CPU-heavy work:
from asgiref.sync import sync_to_async
async def report(request):
rows = [r async for r in Sale.objects.filter(day=today())] # async ORM
pdf = await sync_to_async(render_pdf, thread_sensitive=False)(rows) # CPU or blocking library
return HttpResponse(pdf, content_type="application/pdf")
Django protects the most common mistake: calling the sync ORM from async code raises SynchronousOnlyOperation, covered in avoiding SynchronousOnlyOperation in Django. Other blocking calls get no such guard. sync_to_async(..., thread_sensitive=False) runs a function in the default thread pool; keep thread_sensitive=True (the default) for code that touches the database or other thread-bound state. To find blocking calls that slipped through, run with asyncio debug mode as in finding blocking calls with asyncio debug mode.
Verify: with PYTHONASYNCIODEBUG=1 in a staging run, no "Executing ... took" warnings name your views.
5. Choose async views where they pay¶
Async views are not a general speed-up. For a view that renders a template from a fast query, measured throughput was the same either way — 1,385–1,913 requests per second for an async hello-world against 1,585–1,815 for sync across runs, and 914–1,018 for an async single-row read against 849 for sync. Where they change outcomes:
# Good candidates: mostly waiting
async def proxy(request): ... # upstream HTTP calls
async def notify(request): ... # fan-out to several services
async def events(request): ... # long-lived streaming responses
# No benefit: mostly CPU or fast DB
def product_page(request): ... # template render + one indexed query
def admin_export(request): ... # CPU-bound; belongs in a worker
Mixing is fine: sync and async views coexist in one project, and Django adapts each to the server. The adaptation cost is small — a thread hop per sync call — and measurable mainly in middleware, as shown in writing async Django middleware. The broader decision of when to mix is covered in mixing sync and async views in Django.
Verify: each async view in the project spends most of its time awaiting I/O, confirmed by tracing or by comparing wall time with CPU time.
Verification¶
Async views are doing their job when:
- They spend their time awaiting I/O, and a shared client is reused across requests.
- Concurrent upstream calls run in a
TaskGroupwith per-call timeouts. - No blocking call runs on the loop, checked with asyncio debug mode.
- Throughput under slow-upstream load is far above what the same number of sync workers would manage.
Diagnostic Hook: compare request latency with event-loop lag for each worker. Latency rising while loop lag stays near zero means you are waiting on dependencies — the case async views handle well; latency rising together with loop lag means something in a view is blocking or burning CPU on the loop.
Pitfalls & edge cases¶
- Assuming async is faster. Measured: no throughput gain for fast, CPU-light views.
- Blocking calls in
async def. Only the ORM raises an error; everything else silently stalls the worker. - Per-request HTTP clients. They discard connection reuse.
- Sync views at high concurrency. They work under ASGI, but each holds a thread.
Frequently Asked Questions¶
Are Django async views faster than sync views?
For waiting, much faster in aggregate: 1,000 requests that each waited 100 ms ran at 1,546 req/s under one uvicorn worker against 39 req/s on four gunicorn sync workers. For fast, CPU-light views they measured the same.
Do sync Django views run in a single thread under ASGI?
Not in Django 6.1: each request gets its own thread-sensitive context and thread, so 1,000 sleeping sync requests at 200 concurrent ran at 1,411 req/s. Each one does hold a thread while it runs.
How do I call an external API from a Django async view?
Use a shared httpx.AsyncClient or aiohttp session created once per process and await the call; use a TaskGroup to run several calls concurrently within one request.
Can I mix sync and async views in one Django project?
Yes. Django adapts each view to the server it runs under; the cost is a thread hop per adaptation, which is small for views and more noticeable in middleware.
Related¶
- Django Async — up to the topic overview.
- Using the async Django ORM — database access from async views.
- Network I/O & Protocol Handling — the section overview.