Mixing Sync and Async Views in Django¶
Django supports async views, async middleware and an async ORM interface, and it runs under ASGI servers. Underneath, most of Django — and the ORM in particular — is still synchronous, and the two worlds meet through asgiref's sync_to_async and async_to_sync. The defaults are designed for safety rather than speed, and they decide your concurrency. Measured with Django 6.1 and asgiref 3.12: calling the ORM directly from an async function raised SynchronousOnlyOperation: You cannot call this from an async context - use a thread or sync_to_async.; ten concurrent sync_to_async calls of a 100 ms blocking function took 1.00 s on one thread with the default thread_sensitive=True, and 0.10 s on ten threads with thread_sensitive=False or with a ThreadSensitiveContext per call; and each sync_to_async hop cost about 41 µs. This guide explains those defaults and how to structure a project that mixes both.
Prerequisites¶
- Python 3.11+, Django 5 or 6, run under an ASGI server (uvicorn, daphne, hypercorn) for async views to be worth having.
- The bridge functions, from calling async code from synchronous code safely.
- ASGI basics, from ASGI Servers & Frameworks.
1. Know which code runs where¶
Under ASGI, Django runs async views directly on the event loop and runs sync views — and sync middleware — through sync_to_async, in a thread. Under WSGI it is the reverse: sync views run directly, and async views are wrapped in async_to_sync with a loop per request, much like Flask. Every switch costs a thread hop:
# views.py
import httpx
from django.http import JsonResponse
async def status(request): # runs on the event loop under ASGI
async with httpx.AsyncClient(timeout=3) as client:
r = await client.get("https://status.example.com/api")
return JsonResponse({"upstream": r.status_code})
def report(request): # runs in a thread via sync_to_async under ASGI
rows = list(Order.objects.filter(paid=True).values("id", "total")[:100])
return JsonResponse({"rows": rows})
Mixing is fine, but a request that crosses the boundary repeatedly — async middleware, then a sync middleware, then an async view — pays a hop at each crossing. Django's documentation recommends keeping the middleware stack consistently sync or async for that reason. At 41 µs per hop, a handful of crossings is negligible; dozens per request show up in latency.
Verify: log asyncio.current_task() (or its absence) inside each view and middleware to confirm where it runs.
2. Do not call the ORM directly from async code¶
Django guards its sync-only parts. Calling them on a running event loop raises immediately:
async def broken(request):
n = User.objects.count() # SynchronousOnlyOperation
...
async def fine(request):
n = await User.objects.acount() # async ORM method (Django 4.1+)
names = [u.username async for u in User.objects.filter(is_active=True)]
...
Reproduced: SynchronousOnlyOperation: You cannot call this from an async context - use a thread or sync_to_async. The async ORM methods — aget, acreate, acount, async for over a queryset — are the supported interface. Read the implementation and you will find that, in Django 6.1, acount() is literally return await sync_to_async(self.count)(): the query still runs synchronously, in a thread, through the same database connection machinery. The async interface removes the boilerplate; it does not make the database driver async.
Verify: grep async views for ORM calls without an a prefix or async for; each one will raise at runtime.
3. Understand thread_sensitive and what it serialises¶
sync_to_async defaults to thread_sensitive=True, which runs the sync function in a single shared thread rather than a pool. That protects code that is not thread-safe — Django's database connections are per-thread, and many libraries assume single-threaded use — at the cost of serialising all such calls:
import asyncio
import time
from asgiref.sync import sync_to_async, ThreadSensitiveContext
def blocking():
time.sleep(0.1)
async def main():
await asyncio.gather(*(sync_to_async(blocking)() for _ in range(10))) # 1.00 s
await asyncio.gather(*(sync_to_async(blocking, thread_sensitive=False)() for _ in range(10))) # 0.10 s
async def per_request():
async with ThreadSensitiveContext():
await sync_to_async(blocking)()
await asyncio.gather(*(per_request() for _ in range(10))) # 0.10 s
Measured: 1.00 s on one thread, 0.10 s on ten, and 0.10 s again with one ThreadSensitiveContext per caller. Django's ASGI handler creates a ThreadSensitiveContext per request, so different requests do get different threads, and sync code inside one request stays on one thread. The serialisation bites inside a single request: an async view that fans out three sync_to_async calls with gather runs them one after another.
Verify: in an async view that gathers several sync_to_async calls, total time equals the sum of the calls, not the maximum.
4. Opt out of thread sensitivity only for thread-safe code¶
For code that is genuinely thread-safe and does not touch Django's database connection — a sync HTTP client, a CPU-light parser, a thread-safe SDK — thread_sensitive=False lets concurrent calls use the thread pool:
fetch_rates = sync_to_async(legacy_rates_client.get_rates, thread_sensitive=False)
fetch_stock = sync_to_async(legacy_stock_client.lookup, thread_sensitive=False)
async def product_page(request, sku: str):
rates, stock = await asyncio.gather(fetch_rates("EUR"), fetch_stock(sku)) # in parallel
product = await Product.objects.aget(sku=sku) # ORM: default
return render(request, "product.html", {"p": product, "rates": rates, "stock": stock})
Never pass thread_sensitive=False for ORM calls. The ORM's connection is per thread; running ORM code on arbitrary pool threads opens connections on each, which leak or exhaust the database's connection limit, and transactions no longer span the calls you expect. If you need concurrent queries, use an async driver directly — asyncpg or psycopg 3's async mode — for those queries, as in using psycopg 3 async connections and pools.
Verify: under load, the database's connection count matches what you configured, not one per pool thread.
5. Choose a deployment model deliberately¶
The mix of views decides the server:
# mostly sync views: WSGI with threads, async views pay a loop per request
gunicorn myproject.wsgi -w 4 --threads 8
# a real share of async views: ASGI, sync views run through sync_to_async
gunicorn myproject.asgi -w 4 -k uvicorn.workers.UvicornWorker
Under WSGI, async views get a new loop per request and cannot share async clients, the same constraint described for Flask in calling async libraries from Flask views. Under ASGI, the event loop is long-lived, so async views can share clients created at startup, and long-lived connections such as websockets and SSE become practical. The cost is that every sync view and sync middleware pays a thread hop, and per-request sync work is serialised by thread sensitivity. Projects with a few async endpoints in a large sync codebase often do best running two deployments — WSGI for the bulk, ASGI for the streaming endpoints — behind one router.
Verify: measure p99 latency of the busiest sync view under both servers before switching.
Verification¶
The mix is healthy when:
- No async view calls sync ORM methods directly; they use the
a-prefixed methods orasync for. thread_sensitive=Falseis used only for thread-safe, non-ORM code.- The middleware stack is consistently sync or async, minimising hops.
- The server matches the view mix, verified by latency measurements.
Diagnostic Hook: export the number of sync_to_async invocations per request and the time spent inside them. A request with many hops is paying for crossings it may not need; a high share of time inside thread-sensitive calls in an async view means its "concurrent" fan-out is running serially.
Pitfalls & edge cases¶
gatherover ORM calls. They run one after another on the request's single sync thread.- Sync clients in async views without
sync_to_async. They block the event loop for every request on the worker. - Async clients created per request under WSGI. Works, but loses connection reuse; that is a sign the endpoint belongs on ASGI.
- Assuming the async ORM is non-blocking. It runs the same sync queries in a thread.
Frequently Asked Questions¶
Why do I get SynchronousOnlyOperation in Django?
Synchronous, thread-bound code such as the ORM was called directly from an async context. Use the async ORM methods like aget, acount and async for, or wrap the call in sync_to_async.
Is Django's async ORM really asynchronous?
The interface is, but the queries are not: in Django 6.1, acount is implemented as await sync_to_async(self.count)(). The query runs synchronously in a thread through the normal connection machinery.
What does thread_sensitive do in sync_to_async?
With the default thread_sensitive=True, functions run in one shared thread per context, so non-thread-safe code is safe but calls are serialised. Ten concurrent 100 ms calls took 1.0 s; with thread_sensitive=False they took 0.1 s.
Should I run Django under WSGI or ASGI?
WSGI for mostly synchronous apps, ASGI when async views, websockets or streaming responses matter. Under ASGI every sync view and sync middleware pays a thread hop, so measure before switching.
Related¶
- Hybrid Concurrency Models — up to the topic overview.
- Replacing thread-local state in async code — why Django's per-thread state needs care in async code.
- Concurrent Execution & Worker Patterns — the section overview.