Filter concepts by levelShowing all levels.

Django · Async

Async Django

async def views and the ASGI requirement, the sync/async bridge and the ORM, cancellation, timeouts and limits

  • async def describes how you WAITit makes nothing faster on its own — the gain is overlapping I/O
  • One sync middleware erases the benefitthe docs require a fully async chain, not just an async view
  • The a-prefixed ORM is ergonomicsno async driver underneath — the query still blocks a thread
  • Transactions do not work in async modekeep the whole atomic() unit inside one sync_to_async call
  • Coroutines are cheap, so the ceiling is gonegather over 5,000 URLs opens 5,000 connections without a Semaphore

1.async def views, await, async middleware, and the ASGI requirement

Writing `async def` in front of a view makes it a coroutine, which lets you `await` inside it. It does not, on its own, make anything faster. Under WSGI, Django runs each async view in its own one-off event loop — you can still `await` concurrent HTTP calls, but you get none of the concurrency benefit of an async stack and you pay roughly a millisecond of context-switching per request. Real concurrency needs ASGI, and the Django docs add a condition people miss: you only get a fully asynchronous stack if **no synchronous middleware is loaded**, because one sync middleware forces a thread switch for every request and can erase the advantage entirely.

Key features

  • Under WSGI, Django runs an async view in its own one-off event loop — it works, but there is no concurrency benefit and about a millisecond of context-switching cost
  • The docs are explicit that a fully asynchronous stack requires **no synchronous middleware** loaded; one sync middleware forces a thread switch for every request
  • Middleware declares `sync_capable` and `async_capable` class attributes; Django adapts at each mismatch, and logs "Asynchronous handler adapted for middleware …" at debug level
  • A blocking call inside an `async def` view stalls the whole event loop, not just that request — the cost is paid by every other connection on that worker
  • Async views shine on I/O you actually await concurrently — several upstream HTTP calls with `asyncio.gather()` — and do nothing at all for CPU-bound work
  • Switching a project to ASGI is a deployment change (`ASGI_APPLICATION`, an ASGI server) plus a middleware audit, not just a keyword in front of a view

What `async def` actually buys, by deployment

What `async def` actually buys, by deployment
DeploymentAsync view runs?Concurrency gain?Cost
WSGI (Gunicorn sync worker)yes, in a one-off loop**no**~1 ms context switch per request
ASGI, all middleware async-capableyesyesnone
ASGI, one sync middlewareyeslargely losta thread switch per request
ASGI, blocking call in the viewyes**negative**the whole event loop stalls
One request, and where the async advantage is won or lost
WSGIASGIyesallasync-capableblocking driver/ CPU workgenuinelyawaitable I/O

Request

Which server?

WSGI or ASGI — decided at deploy, not in the view

WSGI: one-off event loop per request

the view runs; the concurrency does not

ASGI: the middleware chain

every sync middleware forces a thread switch

Any synchronous middleware?

one is enough to erase the gain

async def view

a promise not to block, which Python cannot enforce

A blocking call inside it

stalls the loop for every other connection on this worker

await on real async I/O

asyncio.gather over several upstream calls

Concurrency actually gained

Overhead, and nothing else

  • Request
    • leads to Which server?
  • Which server? — WSGI or ASGI — decided at deploy, not in the view
    • on error, leads to WSGI: one-off event loop per request (WSGI)
    • leads to ASGI: the middleware chain (ASGI)
  • WSGI: one-off event loop per request — the view runs; the concurrency does not
    • leads to Overhead, and nothing else
  • ASGI: the middleware chain — every sync middleware forces a thread switch
    • leads to Any synchronous middleware?
  • Any synchronous middleware? — one is enough to erase the gain
    • on error, leads to Overhead, and nothing else (yes)
    • leads to async def view (all async-capable)
  • async def view — a promise not to block, which Python cannot enforce
    • on error, leads to A blocking call inside it (blocking driver / CPU work)
    • leads to await on real async I/O (genuinely awaitable I/O)
  • A blocking call inside it — stalls the loop for every other connection on this worker
    • leads to Overhead, and nothing else
  • await on real async I/O — asyncio.gather over several upstream calls
    • leads to Concurrency actually gained
  • Concurrency actually gained
  • Overhead, and nothing else

Command

python
async def dashboard(request):
    prices, weather = await asyncio.gather(fetch_prices(), fetch_weather())
    return JsonResponse({"prices": prices, "weather": weather})

`async def` describes how a view *waits*; it makes nothing faster by itself. Under WSGI each async view gets a one-off event loop, so there is no concurrency gain and about a millisecond of overhead. Under ASGI the gain is real, but only if no synchronous middleware is loaded — one is enough to force a thread switch per request — and only if every I/O call inside is genuinely awaitable, because one blocking call stalls the event loop for every other connection on that worker.

2.The async ORM, sync_to_async, async_to_sync, and database access

Django's ORM has an async interface — `aget()`, `acreate()`, `afirst()`, `asave()`, `aset()`, and `async for` over any queryset. What it does **not** have is an async database driver underneath: those methods run the same blocking query in a thread, so they are safe to call from a coroutine rather than genuinely concurrent. Calling the ordinary sync ORM from a coroutine raises `SynchronousOnlyOperation`. Two adapters bridge the gap in each direction: `sync_to_async(fn)` runs blocking code from async, defaulting to `thread_sensitive=True` so it uses the main thread that database connections belong to; `async_to_sync(coro)` runs a coroutine from ordinary sync code. And one limitation is worth knowing before you design around it: **transactions do not yet work in async mode**.

Key features

  • The async ORM interface is `aget`, `acreate`, `aupdate`, `adelete`, `afirst`, `alast`, `acount`, `aexists`, `asave`, `aset`, plus `async for` over a queryset
  • These methods do **not** use an async database driver — they run the same blocking query in a thread, so they are safe from async code but not concurrent
  • Calling the sync ORM directly from a coroutine raises `SynchronousOnlyOperation` — the guard exists because the ORM is not safe on an event loop thread
  • `sync_to_async(fn, thread_sensitive=True)` is the default: the function runs on the main thread, shared with other thread-sensitive calls, which is what Django's thread-local connections require
  • `thread_sensitive=False` gives a brand-new thread and real parallelism — safe only for code that touches no Django state
  • **Transactions do not yet work in async mode** — keep an entire transactional unit inside one `sync_to_async` call rather than spanning `await` points

Crossing the boundary, in both directions

Crossing the boundary, in both directions
You are inYou need to callUseWatch out for
an async viewthe ORM`await Model.objects.aget(...)`a thread underneath — no real concurrency
an async viewa blocking library`await sync_to_async(fn)(...)`the default `thread_sensitive=True` shares the main thread
an async viewa pure CPU function`sync_to_async(fn, thread_sensitive=False)`safe only if it touches no Django state
an async viewa transaction`await sync_to_async(do_it_all)()`transactions do not work in async mode
sync code (a task, a command)a coroutine`async_to_sync(coro)(...)`creates or reuses a loop; preserves contextvars
What actually happens on each side of the bridge
Event loop
async view
Main thread
Database
  1. 1runs the coroutine
  2. 2await Order.objects.aget(pk=57)the a-prefixed method hands a BLOCKING query to a thread
  3. 3SELECT … (synchronous driver)
  4. 4row
  5. 5awaited valuethe loop was free meanwhile — but the query itself never overlapped another
  6. 6await sync_to_async(charge_and_record)(order)thread_sensitive=True → the SAME main thread, so connections and transactions line up
  7. 7BEGIN … COMMITthe whole transaction lives inside one sync_to_async call
  8. 8Order.objects.get(pk=57) — the plain sync ORM
  9. 9SynchronousOnlyOperationthe guard against running the ORM on the loop thread
  1. Event loop → async view: runs the coroutine
  2. async view → Main thread: await Order.objects.aget(pk=57) (the a-prefixed method hands a BLOCKING query to a thread)
  3. Main thread → Database: SELECT … (synchronous driver)
  4. Database → Main thread: row
  5. Main thread → async view: awaited value (the loop was free meanwhile — but the query itself never overlapped another)
  6. async view → Main thread: await sync_to_async(charge_and_record)(order) (thread_sensitive=True → the SAME main thread, so connections and transactions line up)
  7. Main thread → Database: BEGIN … COMMIT (the whole transaction lives inside one sync_to_async call)
  8. async view → Event loop: Order.objects.get(pk=57) — the plain sync ORM
  9. Event loop → async view: SynchronousOnlyOperation (the guard against running the ORM on the loop thread)

Command

python
from asgiref.sync import sync_to_async, async_to_sync

order = await Order.objects.aget(pk=pk)          # async ORM interface
result = await sync_to_async(blocking_fn)(arg)   # thread_sensitive=True by default

The `a`-prefixed ORM methods are ergonomics, not concurrency — there is no async database driver, so each one still runs a blocking query in a thread. The plain sync ORM from a coroutine raises `SynchronousOnlyOperation`. `sync_to_async` defaults to `thread_sensitive=True`, which shares the main thread that Django's thread-local connections need; `thread_sensitive=False` gives real parallelism and is safe only for code touching no Django state. Transactions do not work in async mode — keep the whole `atomic()` unit inside one `sync_to_async` call.

3.Async HTTP clients, cancellation, timeouts, and concurrency limits

An async view is only concurrent if its I/O library is awaitable, which in practice means `httpx.AsyncClient` or `aiohttp` rather than `requests`. Once you are awaiting real I/O, three things become your responsibility. **Timeouts**: `await` has no deadline of its own, so a stalled upstream holds a coroutine and a connection forever unless you set one. **Cancellation**: when a client disconnects or a timeout fires, the coroutine receives `asyncio.CancelledError` at its next `await` — which means cleanup belongs in `finally`, and swallowing that exception breaks shutdown. **Concurrency limits**: async removes the natural ceiling that a thread pool provided, so a fan-out over a thousand items will happily open a thousand upstream connections unless a `Semaphore` says otherwise.

Key features

  • `requests` is blocking and stalls the event loop; the awaitable clients are `httpx.AsyncClient` and `aiohttp.ClientSession`
  • `await` has no implicit deadline — always pass a timeout to the client, or wrap the block in `async with asyncio.timeout(...)`
  • `asyncio.timeout()` works by cancelling the block it wraps, so a timeout and a client disconnect both arrive as `CancelledError`
  • `CancelledError` inherits from `BaseException`, so `except Exception` does not catch it — cleanup belongs in `finally`, and re-raising is mandatory
  • A `Semaphore` is what replaces the accidental ceiling a thread pool used to provide: `asyncio.gather` over 1,000 items opens 1,000 connections otherwise
  • Reuse one client per fan-out — creating an `AsyncClient` per request discards the connection pool and pays a TLS handshake every time

What async removes, and what you must add back

What async removes, and what you must add back
Sync behaviourUnder asyncWhat you add
Thread pool caps concurrencycoroutines are nearly free — no ceiling`asyncio.Semaphore(n)`
Socket timeout on the blocking call`await` waits indefinitelya client timeout, or `asyncio.timeout()`
A dead client is noticed on writethe coroutine is cancelled at its next `await``finally` cleanup; re-raise `CancelledError`
One connection per threadone pool shared by many coroutinesone long-lived client, not one per request
The life of one awaited call, including the two ways it ends early
asyncwith sema slotis freeupstreamresponds in timedeadlinepassesthe callergoes awaytimeout cancelsthe blockthe servercancels the taskexcept Exceptiondoes NOT catch this

Coroutine scheduled

start

Waiting on the semaphore

Request in flight — awaiting I/O

Response received

end

asyncio.timeout fires

Client disconnected

CancelledError raised at the next await

finally: release the connection and the slot

end

  • Coroutine scheduled (start)
    • → Waiting on the semaphore when async with sem
  • Waiting on the semaphore
    • → Request in flight — awaiting I/O when a slot is free
  • Request in flight — awaiting I/O
    • → Response received when upstream responds in time
    • → asyncio.timeout fires when deadline passes
    • → Client disconnected when the caller goes away
  • Response received (end)
  • asyncio.timeout fires
    • → CancelledError raised at the next await when timeout cancels the block
  • Client disconnected
    • → CancelledError raised at the next await when the server cancels the task
  • CancelledError raised at the next await
    • → finally: release the connection and the slot when except Exception does NOT catch this
  • finally: release the connection and the slot (end)

Command

python
async with asyncio.timeout(5):
    async with sem:                     # bound the fan-out
        response = await client.get(url)

Concurrency needs an awaitable client — `httpx.AsyncClient` or `aiohttp`, never `requests`. `await` has no deadline, so set a per-request timeout and, for a job, an outer `asyncio.timeout()`. Both a timeout and a client disconnect arrive as `CancelledError`, which inherits from `BaseException`: clean up in `finally`, catch it only to re-raise, and never swallow it or shutdown will hang. And because coroutines are cheap, the thread pool's accidental ceiling is gone — a `Semaphore` is how you put it back.

Remember

  • Under WSGI, Django runs an async view in a one-off event loop: it works, gains nothing, and costs about a millisecond per request.
  • A blocking call inside an async view stalls the event loop for every other connection on that worker — not just for that request.
  • sync_to_async defaults to thread_sensitive=True, which shares the main thread Django's thread-local connections need; False is only for code touching no Django state.
  • CancelledError inherits from BaseException, so `except Exception` will not catch it — clean up in finally, and always re-raise.

Pro tip

  • Before rewriting views as async, audit MIDDLEWARE and every I/O library: if either is still synchronous, the refactor buys overhead and nothing else.
  • Set two deadlines, not one — a per-request timeout for a slow upstream, and an outer asyncio.timeout() for the whole job.

Next up

  • ASGI vs WSGI
  • Background Jobs
  • Celery

Sources

  • Django asynchronous support — docs.djangoproject.com/en/5.2/topics/async/
  • Every claim checked against Django 5.2 LTS docs · checked 2026-09-04

Spotted a mistake on this page? Tell us.