Async Support
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1 on PyPI, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python
3.12-3.14 and asgiref 3.12.1, all checked on 2026-10-03; differences in the 5.2 LTS series are called out where they
matter (the main ones: AsyncPaginator and async Page objects are new in 6.0, and several async helpers on users,
sessions and the ORM arrived between 4.1 and 5.2). Every listing was run in a scratch project on Python 3.13 and Django
6.1.1 with SQLite: manage.py check and a suite of 43 tests pass, and the project was served with Uvicorn, Gunicorn
with Uvicorn workers, Hypercorn, Daphne and Granian to compare behaviour. Django has had ASGI support since 3.0, async
views since 3.1 and an async ORM interface since 4.1, but it is still a framework whose core is synchronous: the database
layer runs in threads, and every async API is either a genuinely non-blocking path or a thin sync_to_async wrapper
around sync code. Knowing which is which is what this page is about. In the Bookshelf application that grows across
this section the running example is a catalogue of books whose views fan out to slow partner services concurrently.
For the language side — the event loop, coroutines, asyncio.gather, threads versus processes — read
Concurrency and Async first; this page assumes it.
In a hurry: what goes where
| Need | Rule of thumb | Section |
|---|---|---|
Serve async code without blocking |
Deploy the |
|
Call several slow services in one request |
|
|
Query the database from async code |
The |
|
Call sync code from async code |
|
|
Call async code from sync code |
|
|
Got |
You called a blocking Django API inside a running event loop; wrap it, use the async variant, or move it out. |
|
Write middleware that does not force a thread switch |
Declare |
|
Users, sessions, cache, signals |
|
|
Stream a long response |
|
Sync versus async request handling under ASGI
Django decides, per view and per middleware, whether to run it on the event loop or in a thread. Under an ASGI server
the handler itself is async; every sync piece in the chain costs a switch into a worker thread (via sync_to_async)
and back. Under WSGI it is the other way round: the handler is sync and an async view is run with async_to_sync, which
starts an event loop just for that call. The figure shows both paths under ASGI.
Two consequences follow from the figure. First, an async view only pays off if everything it awaits is genuinely non-blocking; a blocking call inside it freezes the whole event loop, not one request. Second, every sync middleware or sync view on an ASGI stack costs a thread hop — cheap individually, but not free, and sync views all share one thread per request by default (sync_to_async and async_to_sync).
Source: Async views and Performance
ASGI: the async entry point
ASGI (Asynchronous Server Gateway Interface) is the async successor of WSGI. startproject creates both entry points:
wsgi.py exposes application = get_wsgi_application() and asgi.py exposes the ASGI equivalent. Nothing in a view
changes between them; what changes is which server you run and how Django adapts sync and async code.
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_asgi_application()
Call get_asgi_application() before importing anything that touches models: it calls django.setup(), so code that
imports models first fails with AppRegistryNotReady. To add ASGI middleware or mount Django inside another ASGI
application, wrap the returned object in this file (application = SomeASGIMiddleware(application)).
Source: How to deploy with ASGI
Choosing a server
Django’s deployment guide documents four ASGI servers. All four served the scratch project unchanged:
python -m pip install uvicorn
python -m uvicorn config.asgi:application # Uvicorn
python -m pip install uvicorn uvicorn-worker gunicorn
python -m gunicorn config.asgi:application -k uvicorn_worker.UvicornWorker # Gunicorn managing Uvicorn workers
python -m pip install daphne
daphne config.asgi:application # Daphne
python -m pip install hypercorn
hypercorn config.asgi:application # Hypercorn
python -m pip install granian
granian config.asgi:application --interface asgi # Granian
Note the Gunicorn worker class: uvicorn_worker.UvicornWorker from the separate uvicorn-worker package. Older
material (and many blog posts) use uvicorn.workers.UvicornWorker, which Uvicorn has deprecated in favour of that
package.
Daphne can also replace runserver in development: add "daphne" to INSTALLED_APPS and point ASGI_APPLICATION at
the application object. In the scratch project, putting it anywhere after django.contrib.staticfiles made
manage.py check fail (Channels' daphne.E001), so put it first:
INSTALLED_APPS = ["daphne", *INSTALLED_APPS] # first, before django.contrib.staticfiles
ASGI_APPLICATION = "config.asgi.application"
What Django’s ASGI handler does and does not do
-
It handles the
httpscope only. WebSockets need Channels (see Channels & WebSockets); the ASGIlifespanprotocol is not implemented, so servers log that lifespan is unsupported (Hypercorn printed "ASGI Framework Lifespan error, continuing without Lifespan support" in the scratch run) and start anyway. There is no startup/shutdown hook: open shared clients lazily, or useAppConfig.ready()for sync setup. -
It reads the request body, then runs the middleware chain and view as described in Sync versus async request handling under ASGI.
-
It detects client disconnects while an async view runs and cancels it, raising
asyncio.CancelledErrorinside the view (since Django 5.0; see Handling client disconnects). -
Under ASGI,
runserveris still a WSGI server unless Daphne (or another package) replaces it, so test async behaviour against a real ASGI server before trusting timings.
Source: How to deploy with ASGI and Handling disconnects
Async views
A view is async when calling it returns a coroutine; Django checks this with inspect.iscoroutinefunction. For a
function-based view that means async def. If you build the callable some other way (a decorator, a callable object),
mark it with inspect.markcoroutinefunction (Python 3.12+; asgiref.sync.markcoroutinefunction before that) or Django
will treat it as sync and its coroutine will never be awaited.
import asyncio
import threading
from django.http import HttpResponse, JsonResponse
from django.shortcuts import aget_object_or_404
from .models import Author, Book
from .services import fetch_rating
async def hello(request):
await asyncio.sleep(0.1) # stands in for an awaited network call
return HttpResponse("Hello, async world!")
def where_am_i(request):
"""Works for sync and async views: report the thread and whether it has a loop."""
try:
asyncio.get_running_loop()
has_loop = True
except RuntimeError:
has_loop = False
return JsonResponse({"thread": threading.current_thread().name, "loop_in_thread": has_loop})
async def where_am_i_async(request):
return where_am_i(request)
where_am_i called as a sync view under the async test client reports loop_in_thread: false (Django ran it in a
worker thread); called from the async view it reports true (it is on the event loop). Both tests are in the suite
below.
Source: Async views
Concurrency inside one request
The main thing an async view gives you on any server is cheap concurrency inside a request: start several awaitable operations and wait for all of them. The running example simulates a partner rating API that takes 200 ms per call.
import asyncio
async def fetch_rating(book_id: int) -> dict:
"""Stand-in for a slow upstream HTTP call (an async client would go here)."""
await asyncio.sleep(0.2)
return {"book": book_id, "rating": 4.0 + (book_id % 10) / 10}
async def ratings_sequential(request):
ids = [1, 2, 3, 4, 5]
ratings = [await fetch_rating(i) for i in ids] # five awaits, one after another
return JsonResponse({"ratings": ratings})
async def ratings_concurrent(request):
ids = [1, 2, 3, 4, 5]
ratings = await asyncio.gather(*(fetch_rating(i) for i in ids)) # overlapped
return JsonResponse({"ratings": ratings})
The sequential view takes about 1 s (five 200 ms waits); the concurrent one about 200 ms. In production fetch_rating
would use an async HTTP client such as HTTPX’s AsyncClient (one shared client, not one per call); a sync client such
as requests would block the loop and erase the gain.
Never block the event loop
Under ASGI all async views of a worker process share one event loop. A blocking call — time.sleep(), requests.get(),
a sync ORM query (which Django refuses anyway, see SynchronousOnlyOperation and async safety), heavy CPU work — stops every other request on that
loop until it finishes.
import time
def slow_sync(request):
time.sleep(0.5) # blocks one worker thread
return HttpResponse("slow sync")
async def slow_async_blocking(request):
time.sleep(0.5) # WRONG: blocks the whole event loop
return HttpResponse("slow async, blocking")
async def slow_async_ok(request):
await asyncio.sleep(0.5) # yields to the loop
return HttpResponse("slow async, awaiting")
Five concurrent curl requests to each URL against a single Uvicorn worker (python -m uvicorn
config.asgi:application) on the scratch machine:
$ for p in slow/async-blocking slow/async-ok slow/sync ratings/sequential ratings/concurrent; do
> /usr/bin/time -p sh -c "for i in 1 2 3 4 5; do curl -s -o /dev/null http://127.0.0.1:8759/$p/ & done; wait"
> done
slow/async-blocking real 2.57
slow/async-ok real 0.52
slow/sync real 0.53
ratings/sequential real 1.03
ratings/concurrent real 0.22
The blocking async view serialises all five requests (5 x 0.5 s); the awaiting one overlaps them. The sync view also
overlaps: under ASGI each request gets its own thread-sensitive context, so separate requests' sync views run in
separate threads (within one request, all thread-sensitive calls share a thread). A sync view is therefore not worse
than a badly written async view — it is the badly written async view that is the real hazard. CPU-bound work belongs
in a task queue (see Tasks Framework) or, at most, in sync_to_async with
thread_sensitive=False.
Source: Performance and sync_to_async()
Handling client disconnects
Since Django 5.0, when a client disconnects during a long-running async view under ASGI, Django cancels the view’s task:
asyncio.CancelledError is raised at the current await. Catch it only to clean up, and re-raise it.
import logging
log = logging.getLogger("catalog")
async def long_poll(request):
try:
await asyncio.sleep(5) # waiting for something that never arrives
except asyncio.CancelledError:
log.info("client disconnected, cleaning up")
raise # always re-raise
return HttpResponse("done")
Under WSGI, or for a sync view, there is no such notification. For streaming responses the cancellation arrives inside the async iterator (Streaming responses with async iterators).
Source: Handling disconnects
Decorators on async views
Most of Django’s view decorators work on both sync and async functions: cache_control(), never_cache(),
no_append_slash(), the CSRF decorators (csrf_exempt(), csrf_protect(), ensure_csrf_cookie(),
requires_csrf_token()), sensitive_variables(), sensitive_post_parameters(), gzip_page(), the conditional
decorators (condition(), etag(), last_modified(), conditional_page()), the HTTP-method decorators
(require_http_methods(), require_GET(), require_POST(), require_safe()), vary_on_cookie(),
vary_on_headers(), the X-Frame-Options decorators and, new in 6.0, csp_override() and csp_report_only_override().
login_required(), permission_required() and user_passes_test() also accept async views since Django 5.1.
from django.views.decorators.cache import never_cache
@never_cache
async def my_async_view(request): ...
Source: Decorators and Django 5.1 release notes
Async class-based views
For a class-based view, make the HTTP method handlers (get(), post(), …) async def — not init() and not
as_view(). View.as_view() then returns a coroutine function, and Django’s own fallbacks (options() and
http_method_not_allowed()) are wrapped so they also return coroutines. All handlers on a class must be the same kind:
mixing a sync post() with an async get() makes as_view() raise ImproperlyConfigured ("… must either be all
sync or all async").
import asyncio
from django.http import JsonResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.cache import never_cache
from django.views.generic import TemplateView
from .models import Author, Book
class BookStatsView(View):
"""Every HTTP handler is `async def`, so as_view() returns a coroutine function."""
async def get(self, request, *args, **kwargs):
books, authors = await asyncio.gather(Book.objects.acount(), Author.objects.acount())
return JsonResponse({"books": books, "authors": authors})
async def post(self, request, *args, **kwargs):
book = await Book.objects.acreate(
title=request.POST["title"], author=await Author.objects.afirst()
)
return JsonResponse({"id": book.pk}, status=201)
class MixedView(View):
async def get(self, request):
return JsonResponse({"ok": True})
def post(self, request): # a sync handler next to an async one
return JsonResponse({"ok": True})
class NeverCachedStatsView(BookStatsView):
# Decorating dispatch() needs an async dispatch(), or method_decorator()
# cannot tell that the result is a coroutine function.
@method_decorator(never_cache)
async def dispatch(self, *args, **kwargs):
return await super().dispatch(*args, **kwargs)
class TitlesPage(TemplateView):
template_name = "catalog/titles.html"
async def get(self, request, *args, **kwargs):
context = {"books": Book.objects.all()} # lazy queryset, on purpose
return self.render_to_response(context) # a TemplateResponse
Three details are worth spelling out:
-
method_decorator()works on async methods. When you decoratedispatch()rather than a handler,dispatch()itself must beasync def(awaitingsuper().dispatch()), ormethod_decorator()cannot mark the result as a coroutine function. -
The generic views (
ListView,DetailView, the editing views) are written in sync style: theirget()callsget_queryset(),get_context_data()and friends synchronously. Overridingget()asasync defon them works only if nothing in that call path evaluates a queryset. Prefer a plainViewwith async handlers, or keep generic views sync. -
TitlesPagepasses a lazy queryset to aTemplateResponse, and it works: Django renders template responses after the view returns, insidesync_to_async, so the queryset is evaluated in a thread. Therender()shortcut, by contrast, renders immediately; called inside async code with the same lazy queryset it raisesSynchronousOnlyOperation(testtest_render_shortcut_with_a_lazy_queryset_fails). In async views, either evaluate data first ([b async for b in qs]) or return aTemplateResponse.
class AsyncCBVTests(AsyncTestBase):
async def test_async_handlers(self):
self.assertTrue(inspect.iscoroutinefunction(BookStatsView.as_view()))
data = (await self.async_client.get("/stats/")).json()
self.assertEqual(data, {"books": 3, "authors": 2})
response = await self.async_client.post("/stats/", {"title": "Wild Seed"})
self.assertEqual(response.status_code, 201)
async def test_options_and_405_are_wrapped(self):
response = await self.async_client.options("/stats/")
self.assertEqual(response["Allow"], "GET, POST, HEAD, OPTIONS")
self.assertEqual((await self.async_client.delete("/stats/")).status_code, 405)
def test_mixed_handlers_are_refused_by_as_view(self):
with self.assertRaisesMessage(ImproperlyConfigured, "must either be all sync or all async"):
MixedView.as_view()
async def test_decorated_dispatch(self):
response = await self.async_client.get("/stats-nocache/")
self.assertIn("no-cache", response["Cache-Control"])
async def test_template_response_with_a_lazy_queryset_works(self):
response = await self.async_client.get("/titles/")
self.assertEqual(response.content.decode().strip(), "Dawn;Kindred;The Dispossessed;")
async def test_render_shortcut_with_a_lazy_queryset_fails(self):
from django.shortcuts import render
request = AsyncRequestFactory().get("/")
with self.assertRaises(SynchronousOnlyOperation):
render(request, "catalog/titles.html", {"books": Book.objects.all()})
Source: Async views, Decorators and View
For the rest of the class-based view machinery see Class-Based Views and Generic Views.
Async middleware
Middleware declares what it can handle with two attributes on the factory function or class:
| Attribute | Default | Meaning |
|---|---|---|
|
|
Can be called with a sync |
|
|
Can be called with an async |
So by default middleware is sync-only, and on an ASGI stack Django wraps it with sync_to_async — a thread hop on
every request. The decorators sync_only_middleware(), async_only_middleware() and sync_and_async_middleware() in
django.utils.decorators set both flags on a factory function. When a middleware is both sync- and async-capable,
Django passes it whatever get_response minimises transitions, and the middleware must check which one it got with
inspect.iscoroutinefunction and return a matching callable. All of Django’s bundled middleware is hybrid; many
third-party packages are still sync-only.
import time
from inspect import iscoroutinefunction
from django.utils.decorators import sync_and_async_middleware
@sync_and_async_middleware
def server_timing(get_response):
"""Adds a Server-Timing header; works whichever way Django calls it."""
if iscoroutinefunction(get_response):
async def middleware(request):
start = time.perf_counter()
response = await get_response(request)
response["Server-Timing"] = f"app;dur={(time.perf_counter() - start) * 1000:.1f}"
return response
else:
def middleware(request):
start = time.perf_counter()
response = get_response(request)
response["Server-Timing"] = f"app;dur={(time.perf_counter() - start) * 1000:.1f}"
return response
return middleware
A class-based async-only middleware must mark instances as coroutine functions with markcoroutinefunction(self),
because Django checks the instance, whose call alone does not make it look like one. Hook methods
(process_view(), process_exception(), process_template_response()) should match the mode; if they don’t, Django
adapts each one individually, at an extra cost.
import logging
from inspect import iscoroutinefunction, markcoroutinefunction
log = logging.getLogger("catalog")
class RequestIdMiddleware:
"""Async-only class-based middleware."""
sync_capable = False
async_capable = True
def __init__(self, get_response):
self.get_response = get_response
if iscoroutinefunction(self.get_response):
markcoroutinefunction(self)
async def __call__(self, request):
request.request_id = f"req-{id(request) % 100000}"
response = await self.get_response(request)
response["X-Request-Id"] = request.request_id
return response
async def process_view(self, request, view_func, view_args, view_kwargs):
log.info("process_view %s", view_func.__name__)
return None
class LegacySyncMiddleware:
"""A plain synchronous middleware, the way most third-party packages ship."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
response["X-Legacy"] = "sync"
return response
Source: Middleware: Asynchronous support
Finding the thread hops
With DEBUG = True, Django logs a debug message on the django.request logger for each middleware it adapts
("Asynchronous handler adapted for middleware …"). The tests below turn that into assertions: with
LegacySyncMiddleware in the stack the message appears, with only hybrid middleware it does not, and an async-only
middleware still works under the sync test client because Django adapts it the other way.
HYBRID = "catalog.middleware.server_timing"
ASYNC_ONLY = "catalog.middleware.RequestIdMiddleware"
LEGACY = "catalog.middleware.LegacySyncMiddleware"
BASE = ["django.contrib.sessions.middleware.SessionMiddleware"]
class MiddlewareTests(AsyncTestBase):
@override_settings(MIDDLEWARE=BASE + [HYBRID, ASYNC_ONLY])
async def test_async_stack_end_to_end(self):
response = await self.async_client.get("/hello/")
self.assertTrue(response["X-Request-Id"].startswith("req-"))
self.assertIn("Server-Timing", response)
@override_settings(MIDDLEWARE=BASE + [HYBRID])
def test_hybrid_middleware_in_sync_mode(self):
response = self.client.get("/hello/") # sync client, sync stack
self.assertIn("Server-Timing", response)
@override_settings(DEBUG=True, MIDDLEWARE=BASE + [LEGACY, HYBRID])
async def test_adaptation_is_logged_when_debug_is_on(self):
with self.assertLogs("django.request", level="DEBUG") as logs:
response = await self.async_client.get("/hello/")
self.assertEqual(response["X-Legacy"], "sync")
self.assertTrue(
any("adapted for middleware catalog.middleware.LegacySyncMiddleware" in m for m in logs.output),
logs.output,
)
@override_settings(DEBUG=True, MIDDLEWARE=BASE + [HYBRID])
async def test_no_adaptation_with_async_capable_middleware(self):
with self.assertNoLogs("django.request", level="DEBUG"):
await self.async_client.get("/hello/")
@override_settings(MIDDLEWARE=BASE + [ASYNC_ONLY])
def test_async_only_middleware_under_the_sync_client(self):
response = self.client.get("/hello/") # Django adapts the middleware
self.assertTrue(response["X-Request-Id"].startswith("req-"))
A single sync middleware anywhere between the server and an async view puts at least one thread switch into every
request; worse, Django may then call hybrid middleware below it in sync mode too, to keep transitions down. Audit the
MIDDLEWARE list before claiming an "async stack". The middleware hooks and order are covered in
Middleware.
The async ORM interface
Since Django 4.1 every QuerySet method that runs SQL has an a-prefixed variant, and querysets support async for.
Methods that only build a queryset (filter(), exclude(), order_by(), select_related(), annotate(),
values(), …) have no async variant and need none: they don’t touch the database.
| Sync | Async (Django 6.1) |
|---|---|
Iterating, |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Related managers: |
|
|
|
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=200)
def __str__(self):
return self.name
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, related_name="books", on_delete=models.CASCADE)
pages = models.PositiveIntegerField(default=0)
summary = models.TextField(blank=True)
class Meta:
ordering = ["title"]
def __str__(self):
return self.title
async def book_list(request):
books = [
{"id": b.pk, "title": b.title, "author": b.author.name}
async for b in Book.objects.select_related("author")
]
return JsonResponse({"count": await Book.objects.acount(), "books": books})
async def book_detail(request, pk):
book = await aget_object_or_404(Book.objects.select_related("author"), pk=pk)
return JsonResponse({"id": book.pk, "title": book.title, "author": book.author.name})
async def book_create(request):
"""POST title and author name; shows acreate, aget_or_create and asave."""
author, created = await Author.objects.aget_or_create(name=request.POST["author"])
book = await Book.objects.acreate(title=request.POST["title"], author=author)
book.pages = int(request.POST.get("pages", 0))
await book.asave(update_fields=["pages"])
return JsonResponse({"id": book.pk, "author_created": created}, status=201)
select_related("author") matters here: without it, b.author.name would be a lazy, synchronous query inside the
async comprehension. Raw querysets (Book.objects.raw(…)) can also be iterated with async for, and aiterator()
accepts chunk_size (with prefetch_related() honoured since Django 5.0).
Source: Asynchronous queries, Methods that do not return QuerySets and get_object_or_404() and aget_object_or_404()
The limits
The async ORM is an interface, not an async database driver. Each a-method is the sync method run through
sync_to_async(…, thread_sensitive=True). That has consequences the API surface hides:
-
Queries inside one request do not run in parallel. Thread-sensitive calls from the same request share one thread, so
asyncio.gather()over fiveafirst()calls runs them one after another. The scratch project registers a SQLitesleep()function so a query takes 200 ms (a stand-in for a slow PostgreSQL query):catalog/slowdb.py and catalog/serial_demo.pyimport time from django.db.backends.signals import connection_created from django.db.models import FloatField, Func, Value def add_sleep_function(sender, connection, **kwargs): """SQLite stand-in for PostgreSQL's pg_sleep(): SELECT sleep(0.2).""" if connection.vendor == "sqlite": connection.connection.create_function("sleep", 1, lambda s: time.sleep(s) or 0.0) connection_created.connect(add_sleep_function) SLEEP = Func(Value(0.2), function="sleep", output_field=FloatField()) import asyncio import time from asgiref.sync import sync_to_async from .models import Book from .slowdb import SLEEP def slow_query(): return Book.objects.filter(pk=1).annotate(s=SLEEP).first() async def timed(label, coroutines): start = time.perf_counter() await asyncio.gather(*coroutines) print(f"{label}: {time.perf_counter() - start:.2f}s") async def main(): n = 5 await timed("5 x afirst() (thread-sensitive)", [Book.objects.filter(pk=1).annotate(s=SLEEP).afirst() for _ in range(n)]) await timed("5 x sync_to_async(thread_sensitive=False)", [sync_to_async(slow_query, thread_sensitive=False)() for _ in range(n)]) await timed("5 x asyncio.sleep(0.2) (no database)", [asyncio.sleep(0.2) for _ in range(n)])$ python run_serial.py 5 x afirst() (thread-sensitive): 1.03s 5 x sync_to_async(thread_sensitive=False): 0.21s 5 x asyncio.sleep(0.2) (no database): 0.20sOnly
thread_sensitive=Falseoverlaps the queries — each runs on its own thread with its own connection, outside the request’s transaction context. Use it deliberately, for independent read-only queries, and size the connection pool for it. -
No lazy loading. Accessing an unloaded foreign key, a reverse relation or a deferred field (
defer(),only()) triggers a synchronous query and raisesSynchronousOnlyOperation. Load what you need withselect_related(),prefetch_related()orarefresh_from_db(fields=[…]). The documentation flags the same constraint for Django 6.1’s fetch modes (QuerySet.fetch_mode()): a lazy fetch, whatever the mode, is still a synchronous query, somodels.FETCH_RAISEis useful in async code mainly to make slips visible. -
No transactions.
transaction.atomic()raisesSynchronousOnlyOperationin async code, andATOMIC_REQUESTScannot be combined with async views (RuntimeError: You cannot use ATOMIC_REQUESTS with async views.). Put the transactional unit in a sync function and call it withsync_to_async. -
No raw connections.
connection.cursor()is async-unsafe; wrap the whole cursor block in a sync function. -
Persistent connections. Disable
CONN_MAX_AGEin async mode and use the backend’s pool instead (PostgreSQL’s"pool"option, Django 5.1+). Concurrent requests in one process share that pool, so size it to the in-flight query concurrency you expect.
from asgiref.sync import sync_to_async
from .models import Author
async def fixed_get():
return await Book.objects.aget(pk=1)
async def fixed_iteration():
return [b.title async for b in Book.objects.all()]
async def fixed_len():
return await Book.objects.acount()
async def fixed_foreign_key():
book = await Book.objects.select_related("author").aget(pk=1)
return book.author.name # already loaded by the JOIN
async def fixed_reverse_relation():
author = await Author.objects.prefetch_related("books").aget(name="Octavia Butler")
return [b.title for b in author.books.all()] # served from the prefetch cache
async def fixed_deferred_field():
book = await Book.objects.defer("summary").aget(pk=1)
await book.arefresh_from_db(fields=["summary"])
return book.summary
def create_book_atomically(title, author_id):
"""Plain synchronous code: transactions, signals, cursors all work here."""
with transaction.atomic():
book = Book.objects.create(title=title, author_id=author_id)
transaction.on_commit(lambda: None)
return book.pk
async def fixed_atomic_block():
return await sync_to_async(create_book_atomically)("Found", 1)
async def fixed_template_loop():
template = Template("{% for b in books %}{{ b.title }};{% endfor %}")
books = [b async for b in Book.objects.all()] # evaluate first
return template.render(Context({"books": books}))
Source: Queries & the ORM, Asynchronous queries and Connection pool
Query shaping in general is covered in Making Queries and Query Optimization & Fetch Modes; transactions in Transactions, Raw SQL & Multiple Databases.
sync_to_async and async_to_sync
The two adapters live in asgiref.sync (asgiref is a Django project package and a dependency of Django). Django uses
them internally everywhere sync and async code meet; you use them at the same seams.
| Adapter | Signature | Behaviour |
|---|---|---|
|
|
Returns an async function that runs |
|
|
Returns a sync function that runs |
Both can be used as a wrapper or as a decorator, and both preserve thread-locals and contextvars in both directions.
Thread-sensitive mode exists because database adapters (and a lot of older Django code) assume that a connection is used
from the thread that created it. Within one request, thread-sensitive calls serialise on that request’s worker thread;
separate requests get separate workers, so they do not serialise against each other.
import asyncio
import threading
from asgiref.sync import async_to_sync, sync_to_async
from .models import Book
from .services import fetch_rating
def thread_name():
return threading.current_thread().name
def sync_work(pk):
"""Ordinary blocking code: ORM, signals, a legacy library."""
return Book.objects.get(pk=pk).title
async def adapter_demo():
in_loop = thread_name()
sensitive = await asyncio.gather(*(sync_to_async(thread_name)() for _ in range(5)))
parallel = await asyncio.gather(
*(sync_to_async(thread_name, thread_sensitive=False)() for _ in range(5))
)
title = await sync_to_async(sync_work)(1)
return {"loop": in_loop, "sensitive": sorted(set(sensitive)),
"not_sensitive": sorted(set(parallel)), "title": title}
@sync_to_async
def decorated_helper(pk):
return sync_work(pk).upper()
def call_async_from_sync(book_id):
"""In a synchronous view or a management command."""
return async_to_sync(fetch_rating)(book_id)
async def wrong_direction():
return async_to_sync(fetch_rating)(1) # sync-over-async inside the event loop
Running adapter_demo() under asyncio.run() in the scratch project, then the two sync-side helpers:
$ python run_adapters.py
{'loop': 'MainThread', 'sensitive': ['ThreadPoolExecutor-0_0'], 'not_sensitive': ['asyncio_0', 'asyncio_1'], 'title': 'The Dispossessed'}
THE DISPOSSESSED
RuntimeError You cannot use AsyncToSync in the same thread as an async event loop - just await the async function directly.
{'book': 3, 'rating': 4.3}
Five thread-sensitive calls used exactly one worker thread (not the loop’s thread); the five non-sensitive calls spread
over several. wrong_direction() shows the classic mistake: inside async code you await the coroutine; wrapping it in
async_to_sync() is refused.
The other direction — a plain def view that has to call an async-only library — is async_to_sync():
import threading
from asgiref.sync import async_to_sync
async def async_only_library_call(book_id):
rating = await fetch_rating(book_id)
return {**rating, "coroutine_ran_in": threading.current_thread().name}
def sync_view_calling_async(request, pk):
"""A plain `def` view that needs an async-only library."""
data = async_to_sync(async_only_library_call)(pk)
data["view_ran_in"] = threading.current_thread().name
return JsonResponse(data)
$ python -m uvicorn config.asgi:application --port 8759 &
$ curl -s http://127.0.0.1:8759/sync-calls-async/3/
{"book": 3, "rating": 4.3, "coroutine_ran_in": "MainThread", "view_ran_in": "ThreadPoolExecutor-1_0"}
Under ASGI the sync view ran in a worker thread, and async_to_sync() handed the coroutine back to the server’s event
loop on the main thread rather than starting a new loop. Under WSGI the same call starts and stops an event loop for
each invocation — correct, but that per-call cost is why "sync view calling async code" should stay the exception.
Source: async_to_sync()
Rules for the adapters
-
Never call
sync_to_async(f)without awaiting the result, and neverasync_to_sync()inside async code. -
Wrap a whole unit of sync work in one function and adapt that, rather than adapting many small calls: each crossing is a thread switch.
-
Don’t pass connection objects or their bound methods across the boundary.
sync_to_async(connection.cursor)()resolvesconnectionin the loop thread and calls it in another, and fails withDatabaseError: DatabaseWrapper objects created in a thread can only be used in that same thread …(verified in the scratch project). -
Prefer increasing the database pool over turning off
thread_sensitivefor throughput;thread_sensitive=Falseruns outside the request’s thread, so it cannot see the request’s open transaction or thread-local state that sync code may rely on.
class AdapterTests(AsyncTestBase):
async def test_thread_sensitive_calls_share_one_thread(self):
info = await adapters.adapter_demo()
self.assertEqual(len(info["sensitive"]), 1) # one shared thread ...
self.assertNotEqual(info["sensitive"], [info["loop"]]) # ... never the loop's
self.assertEqual(info["title"], "The Dispossessed")
async def test_decorator_form(self):
self.assertEqual(await adapters.decorated_helper(self.disp.pk), "THE DISPOSSESSED")
def test_async_to_sync_in_sync_code(self):
self.assertEqual(adapters.call_async_from_sync(3)["book"], 3)
async def test_sync_view_calling_async_to_sync(self):
response = await self.async_client.get("/sync-calls-async/3/")
self.assertEqual(response.json()["book"], 3)
async def test_contextvars_cross_the_boundary(self):
import contextvars
var = contextvars.ContextVar("request_id", default="none")
var.set("abc")
self.assertEqual(await sync_to_async(var.get)(), "abc")
self.assertEqual(await sync_to_async(lambda: async_to_sync(self._read)(var))(), "abc")
async def _read(self, var):
return var.get()
Source: Async adapter functions, async_to_sync() and sync_to_async()
SynchronousOnlyOperation and async safety
Parts of Django keep global or per-thread state that is not coroutine-aware; the synchronous ORM API is the main one,
along with raw connections, transactions and anything that runs a query as a side effect (template rendering over a lazy
queryset, a lazy foreign key, len() of a queryset). Django marks them async-unsafe: called from a thread with a
running event loop, they raise django.core.exceptions.SynchronousOnlyOperation. You do not have to be inside an
async def for this to fire — a sync helper called directly (not through sync_to_async) from async code is still on
the event-loop thread.
from django.db import connection, transaction
from django.template import Context, Template
from .models import Book
async def sync_get():
return Book.objects.get(pk=1) # sync ORM call from async code
async def sync_iteration():
return [b.title for b in Book.objects.all()] # iteration runs the query
async def sync_len():
return len(Book.objects.all()) # so does len(), list(), bool()
async def lazy_foreign_key():
book = await Book.objects.aget(pk=1)
return book.author.name # the author is fetched lazily, synchronously
async def deferred_field():
book = await Book.objects.defer("summary").aget(pk=1)
return book.summary # a deferred field loads lazily, synchronously
async def atomic_block():
with transaction.atomic(): # transactions are not async-capable
await Book.objects.acreate(title="Lost", author_id=1)
async def raw_cursor():
with connection.cursor() as cursor: # the connection is async-unsafe
cursor.execute("SELECT 1")
async def template_loop():
template = Template("{% for b in books %}{{ b.title }};{% endfor %}")
return template.render(Context({"books": Book.objects.all()}))
Every one of them fails with the same message in the scratch project:
$ python run_unsafe.py
sync_get -> django.core.exceptions.SynchronousOnlyOperation: You cannot call this from an async context - use a thread or sync_to_async.
sync_iteration -> django.core.exceptions.SynchronousOnlyOperation: You cannot call this from an async context - use a thread or sync_to_async.
...
template_loop -> django.core.exceptions.SynchronousOnlyOperation: You cannot call this from an async context - use a thread or sync_to_async.
The fixes are the safe-calls listing in The limits: use the a-method, async for, load relations up front,
evaluate before rendering, or move the whole block into a sync function behind sync_to_async. In a view the error
becomes a 500 (the scratch project’s /broken/ view, which calls Book.objects.get() inside async def, answers
HTTP/1.1 500 Internal Server Error under Uvicorn):
async def broken(request):
title = Book.objects.get(pk=1).title # synchronous ORM call inside async def
return HttpResponse(title)
Source: Async safety and SynchronousOnlyOperation
DJANGO_ALLOW_ASYNC_UNSAFE
Some environments impose an event loop on you: Jupyter notebooks and IPython shells run one so that top-level await
works, and then ordinary ORM calls raise SynchronousOnlyOperation. In IPython, %autoawait off turns that loop off
(and %autoawait on back on). Where that is not possible and you are certain nothing runs concurrently, setting the
DJANGO_ALLOW_ASYNC_UNSAFE environment variable to any value disables the check. With concurrent access it can corrupt
data; never set it in production. manage.py check --deploy reports it as async.E001 ("You should not set the
DJANGO_ALLOW_ASYNC_UNSAFE environment variable in deployment"), which the suite asserts (test_the_escape_hatch_is_flagged_by_check_deploy below).
Other mistakes the suite pins down
The SynchronousOnlyOperationTests class turns the above into regression tests, together with three neighbouring
mistakes: forgetting await (you get a coroutine object, and AttributeError when you touch .title), calling
async_to_sync() inside the loop (RuntimeError), and ATOMIC_REQUESTS with an async view (RuntimeError).
import asyncio
import inspect
import secrets
import time
from asgiref.sync import async_to_sync, sync_to_async
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.core.cache import cache
from django.db import models
from django.core.exceptions import ImproperlyConfigured, SynchronousOnlyOperation
from django.test import AsyncClient, AsyncRequestFactory, TestCase, override_settings
from . import adapters, pitfalls
from .models import Author, Book
from .signals import book_viewed
from .views_cbv import BookStatsView, MixedView
User = get_user_model()
class AsyncTestBase(TestCase):
@classmethod
def setUpTestData(cls):
cls.le_guin = Author.objects.create(name="Ursula Le Guin")
cls.butler = Author.objects.create(name="Octavia Butler")
cls.disp = Book.objects.create(title="The Dispossessed", author=cls.le_guin, summary="Anarres")
cls.kindred = Book.objects.create(title="Kindred", author=cls.butler)
cls.dawn = Book.objects.create(title="Dawn", author=cls.butler)
class SynchronousOnlyOperationTests(AsyncTestBase):
async def test_sync_orm_calls_are_refused(self):
for func in (
pitfalls.sync_get, pitfalls.sync_iteration, pitfalls.sync_len,
pitfalls.lazy_foreign_key, pitfalls.deferred_field,
pitfalls.atomic_block, pitfalls.raw_cursor, pitfalls.template_loop,
):
with self.subTest(func=func.__name__):
with self.assertRaisesMessage(SynchronousOnlyOperation, "use a thread or sync_to_async"):
await func()
async def test_the_fixed_versions_work(self):
first = await Book.objects.order_by("pk").afirst()
self.assertEqual(await pitfalls.fixed_get(), first)
self.assertEqual(await pitfalls.fixed_iteration(), ["Dawn", "Kindred", "The Dispossessed"])
self.assertEqual(await pitfalls.fixed_len(), 3)
self.assertEqual(await pitfalls.fixed_foreign_key(), "Ursula Le Guin")
self.assertEqual(sorted(await pitfalls.fixed_reverse_relation()), ["Dawn", "Kindred"])
self.assertEqual(await pitfalls.fixed_deferred_field(), "Anarres")
pk = await pitfalls.fixed_atomic_block()
self.assertTrue(await Book.objects.filter(pk=pk).aexists())
self.assertEqual(await pitfalls.fixed_template_loop(), "Dawn;Found;Kindred;The Dispossessed;")
def test_the_same_code_is_fine_in_sync_code(self):
self.assertEqual(pitfalls.Book.objects.get(pk=self.disp.pk).author.name, "Ursula Le Guin")
def test_the_escape_hatch_is_flagged_by_check_deploy(self):
import os
from unittest import mock
from django.core.checks import run_checks
with mock.patch.dict(os.environ, {"DJANGO_ALLOW_ASYNC_UNSAFE": "true"}):
errors = run_checks(include_deployment_checks=True, tags=["async_support"])
self.assertEqual([e.id for e in errors], ["async.E001"])
async def test_forgetting_await_gives_a_coroutine(self):
book = Book.objects.aget(pk=self.disp.pk) # no await
self.assertTrue(asyncio.iscoroutine(book))
with self.assertRaises(AttributeError):
book.title
book.close() # silence "coroutine was never awaited"
async def test_async_to_sync_inside_the_event_loop_is_refused(self):
with self.assertRaisesMessage(RuntimeError, "just await the async function directly"):
await adapters.wrong_direction()
async def test_connection_attributes_do_not_cross_threads(self):
from django.db.utils import DatabaseError
with self.assertRaisesMessage(DatabaseError, "can only be used in that same thread"):
await adapters.pass_connection_attribute()
async def test_atomic_requests_refuses_async_views(self):
from unittest import mock
from django.db import connections
with mock.patch.dict(connections.settings["default"], {"ATOMIC_REQUESTS": True}):
with self.assertRaisesMessage(RuntimeError, "You cannot use ATOMIC_REQUESTS with async views."):
await self.async_client.get("/hello/")
Auth, sessions and cache from async code
The contrib apps gained async twins of their request-time APIs over several releases. They are mostly
sync_to_async wrappers around the sync implementation, so they keep you off SynchronousOnlyOperation rather than
making the work non-blocking — which is what you need inside an async view.
| Sync | Async | Since |
|---|---|---|
|
|
5.0 |
|
|
5.0 |
|
|
5.0 |
|
|
5.2 |
|
(same decorators) |
5.1 |
|
|
5.1 |
|
|
4.0 |
Users and login
request.user is a lazy object: the first attribute access loads the user from the session and the database,
synchronously. In an async view that access raises SynchronousOnlyOperation (test
test_request_user_is_lazy_and_synchronous); use await request.auser() instead.
from asgiref.sync import sync_to_async
from django.contrib.auth import aauthenticate, alogin, alogout
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.views.decorators.http import require_POST
@require_POST
async def login_view(request):
user = await aauthenticate(
request, username=request.POST["username"], password=request.POST["password"]
)
if user is None:
return JsonResponse({"error": "invalid credentials"}, status=400)
await alogin(request, user)
return JsonResponse({"user": user.get_username()})
@require_POST
async def logout_view(request):
await alogout(request)
return JsonResponse({"user": None})
async def whoami(request):
user = await request.auser() # the async twin of request.user
return JsonResponse({"authenticated": user.is_authenticated, "user": user.get_username()})
async def whoami_wrong(request):
return JsonResponse({"user": request.user.get_username()}) # lazy, synchronous query
@login_required
async def can_add_books(request):
user = await request.auser()
return JsonResponse({"can_add": await user.ahas_perm("catalog.add_book")})
Login and logout are POST-only, and CSRF protection applies as usual (CsrfViewMiddleware is in MIDDLEWARE; the
Django test client skips the check by default, a browser form must send {% csrf_token %}). Test passwords come from
secrets.token_urlsafe(), never a literal.
Source: How to log a user in, HttpRequest.auser() and User.ahas_perm()
Sessions, cache and the remaining helpers
from django.core.cache import cache
from django.core.paginator import AsyncPaginator
from django.shortcuts import aget_object_or_404
from .models import Book
from .services import fetch_rating
from .signals import book_viewed
from .tasks import reindex_book
async def visits(request):
count = await request.session.aget("visits", 0) + 1
await request.session.aset("visits", count)
return JsonResponse({"visits": count})
async def cached_rating(request, pk):
key = f"rating:{pk}"
rating = await cache.aget(key)
hit = rating is not None
if not hit:
rating = await fetch_rating(pk)
await cache.aset(key, rating, timeout=60)
return JsonResponse({"hit": hit, "rating": rating})
async def viewed(request, pk):
book = await aget_object_or_404(Book, pk=pk)
responses = await book_viewed.asend(sender=Book, book_id=book.pk)
return JsonResponse({"receivers": sorted(str(r) for _, r in responses)})
async def paged(request):
paginator = AsyncPaginator(Book.objects.order_by("title"), per_page=2)
page = await paginator.aget_page(request.GET.get("page"))
titles = [b.title for b in await page.aget_object_list()]
return JsonResponse({
"page": page.number,
"pages": await paginator.anum_pages(),
"has_next": await page.ahas_next(),
"titles": titles,
})
async def reindex(request, pk):
result = await reindex_book.aenqueue(book_id=pk)
return JsonResponse({"status": str(result.status), "value": result.return_value})
The cache’s async methods exist on BaseCache since 4.0, but the documentation is explicit that Django "does not yet
support asynchronous caching": the built-in backends implement the a-methods by running the sync method in a thread.
The Redis backend added in 4.0 is no exception. Session a-methods (5.1) behave the same way for the database, cache
and file engines.
Async signals
Signal.asend() and Signal.asend_robust() (Django 5.0) are coroutines. Receivers can be sync or async; Django adapts
each one — sync receivers run through sync_to_async() under asend(), async receivers through async_to_sync()
under send(). To keep the number of switches down, receivers are grouped by kind before they are called, so an async
receiver registered before a sync one may run after it. Async receivers run concurrently; since Django 6.1 they run in
an asyncio.TaskGroup (older versions used asyncio.gather()).
import logging
from django.dispatch import Signal, receiver
log = logging.getLogger("catalog")
book_viewed = Signal()
@receiver(book_viewed)
def count_view_sync(sender, book_id, **kwargs):
log.info("sync receiver saw book %s", book_id)
return "sync"
@receiver(book_viewed)
async def count_view_async(sender, book_id, **kwargs):
log.info("async receiver saw book %s", book_id)
return "async"
The viewed view above sends with await book_viewed.asend(…); both receivers run and their return values come
back as (receiver, response) pairs. Note what does not change: the model signals (pre_save, post_save, …) are
sent with send() even when you call asave(), because only the signals of the async request-response cycle are
dispatched with asend(). An async receiver on post_save is therefore wrapped in async_to_sync(). Signals in
general are covered in Signals.
Source: Sending signals ("Changed in Django 6.1" note on TaskGroup) and Signal.asend()
class AsyncServicesTests(AsyncTestBase):
async def test_login_whoami_logout_and_permissions(self):
password = secrets.token_urlsafe(16)
user = await User.objects.acreate_user("ada", password=password) # 5.2
client = AsyncClient()
self.assertFalse((await client.get("/whoami/")).json()["authenticated"])
bad = await client.post("/login/", {"username": "ada", "password": "wrong"})
self.assertEqual(bad.status_code, 400)
ok = await client.post("/login/", {"username": "ada", "password": password})
self.assertEqual(ok.json(), {"user": "ada"})
self.assertEqual((await client.get("/whoami/")).json()["user"], "ada")
self.assertFalse((await client.get("/can-add/")).json()["can_add"])
perm = await Permission.objects.aget(codename="add_book")
await user.user_permissions.aadd(perm) # related-manager async method, 4.2
user = await User.objects.aget(pk=user.pk) # drop the cached permissions
self.assertTrue(await user.ahas_perm("catalog.add_book"))
await client.post("/logout/")
self.assertEqual((await client.get("/can-add/")).status_code, 302) # login_required
async def test_alogin_helper_of_the_test_client(self):
user = await User.objects.acreate_user("grace", password=secrets.token_urlsafe(16))
client = AsyncClient()
await client.aforce_login(user) # 5.0
self.assertEqual((await client.get("/whoami/")).json()["user"], "grace")
async def test_request_user_is_lazy_and_synchronous(self):
user = await User.objects.acreate_user("alan", password=secrets.token_urlsafe(16))
client = AsyncClient()
await client.aforce_login(user)
with self.assertRaises(SynchronousOnlyOperation):
await client.get("/whoami-wrong/")
async def test_sessions(self):
client = AsyncClient()
self.assertEqual((await client.get("/visits/")).json(), {"visits": 1})
self.assertEqual((await client.get("/visits/")).json(), {"visits": 2})
session = await client.asession() # 5.0
self.assertEqual(await session.aget("visits"), 2)
async def test_cache(self):
await cache.aclear()
self.assertFalse((await self.async_client.get("/rating/4/")).json()["hit"])
self.assertTrue((await self.async_client.get("/rating/4/")).json()["hit"])
async def test_signals_sync_and_async_receivers(self):
with self.assertLogs("catalog", level="INFO") as logs:
data = (await self.async_client.get(f"/viewed/{self.disp.pk}/")).json()
self.assertEqual(data["receivers"], ["async", "sync"])
self.assertEqual(len(logs.output), 2)
robust = await book_viewed.asend_robust(sender=Book, book_id=1)
self.assertEqual(len(robust), 2)
async def test_async_paginator(self):
page1 = (await self.async_client.get("/paged/")).json()
self.assertEqual(page1, {"page": 1, "pages": 2, "has_next": True, "titles": ["Dawn", "Kindred"]})
page2 = (await self.async_client.get("/paged/?page=2")).json()
self.assertEqual(page2["titles"], ["The Dispossessed"])
self.assertFalse(page2["has_next"])
async def test_aenqueue(self):
data = (await self.async_client.get(f"/reindex/{self.disp.pk}/")).json()
self.assertEqual(data, {"status": "SUCCESSFUL", "value": f"indexed {self.disp.pk}"})
Source: Testing asynchronous code and Asynchronous support
Streaming responses with async iterators
Since Django 4.2, StreamingHttpResponse accepts an async iterator. The iterator type should match the protocol:
under ASGI an async iterator is consumed on the event loop without tying up a thread for the whole response, which is
what makes long-lived responses (server-sent events, NDJSON feeds, slow exports) practical. Under WSGI use a sync
iterator. A mismatched iterator still works — Django maps it to the right kind and raises a warning — but the
conversion consumes the whole iterator into memory first, which defeats streaming. StreamingHttpResponse.is_async
tells middleware which kind it holds, so a middleware that wraps streaming_content can preserve it.
import asyncio
import json
import logging
from django.http import StreamingHttpResponse
log = logging.getLogger("catalog")
async def book_feed(request):
"""Newline-delimited JSON, one book at a time, from an async generator."""
async def rows():
try:
async for book in Book.objects.order_by("title").values("id", "title").aiterator(chunk_size=100):
yield json.dumps(book) + "\n"
await asyncio.sleep(0) # a real feed would await new data here
except asyncio.CancelledError:
log.info("feed client went away")
raise
return StreamingHttpResponse(rows(), content_type="application/x-ndjson")
$ curl -s -N http://127.0.0.1:8759/feed/ | head -1
{"id": 3, "title": "Dawn"}
If the client disconnects mid-stream under ASGI, the cancellation surfaces inside the generator as
asyncio.CancelledError, where you can release resources before re-raising. The test client consumes async streaming
content with async for chunk in response.streaming_content (test_streaming_with_an_async_iterator in the suite);
Django 6.1 lets assertContains() and assertNotContains() be called several times on the same streaming response (they used to consume its content). CSV and file streaming in
general are covered in Responses, Streaming, CSV & PDF.
AsyncPaginator
Django 6.0 added django.core.paginator.AsyncPaginator and AsyncPage. They mirror Paginator and Page, except
that everything that may run a query is a coroutine: acount(), anum_pages() and apage_range() replace the
count, num_pages and page_range attributes; aget_page() and apage() replace get_page() and page(); and on
the page, aget_object_list(), ahas_next(), ahas_previous() and friends. The paged view in
Auth, sessions and cache from async code shows the pattern; test_async_paginator checks the two pages:
paginator = AsyncPaginator(Book.objects.order_by("title"), per_page=2)
page = await paginator.aget_page(request.GET.get("page"))
titles = [b.title for b in await page.aget_object_list()]
On 5.2 LTS there is no AsyncPaginator: evaluate the page in a sync helper and call it with sync_to_async. Sync
pagination, ListView and templates are covered in Pagination.
Source: AsyncPaginator class and AsyncPage class
Background work from async views
Django 6.0’s Tasks framework has an async enqueue: await task.aenqueue(…), used by the reindex view above and
tested by test_aenqueue (with the development ImmediateBackend, the result is already SUCCESSFUL). For work that
should outlive the request, that — not asyncio.create_task() in a view — is the right tool: a bare task created in a
view is not awaited by anything, is lost on a worker restart, and is not cancelled with the request. Details in
Tasks Framework.
from django.tasks import task
@task
def reindex_book(book_id):
return f"indexed {book_id}"
Source: Tasks: Asynchronous support and Tasks reference
Performance trade-offs
Async is a concurrency tool, not a speed-up. The measurements on this page sum up when it pays:
| Situation (scratch project, one Uvicorn worker) | Wall time | Why |
|---|---|---|
Five requests to an async view that blocks with |
2.57 s |
The loop is frozen; requests run one by one. |
Five requests to an async view that awaits |
0.52 s |
Waits overlap on the loop. |
Five requests to a sync view that sleeps 0.5 s |
0.53 s |
Each request’s sync code runs in its own thread. |
One request, five 200 ms upstream calls, sequential awaits |
1.03 s |
Awaiting one at a time is still serial. |
One request, five 200 ms upstream calls, |
0.22 s |
The real win: fan-out inside a request. |
|
1.03 s |
The async ORM serialises a context’s queries on one thread. |
From this and the official performance notes:
-
Adaptation cost. Each sync/async crossing costs tens of microseconds inside an ASGI request (the running loop is reused) and a few hundred microseconds on the cold path (management commands, scripts, tasks), more under GIL contention as threads multiply. Never cross inside a tight loop: put the loop inside one
sync_to_async()call. -
Middleware. With an ASGI server and an all-sync stack, Django switches once, before the middleware. A sync middleware between the server and an async view adds a switch each way, and Django holds that thread for exception propagation — which caps concurrency exactly where async was meant to raise it (long-lived requests, fan-out, SSE).
-
Database. The async ORM does not make queries concurrent and does not reduce connection needs; concurrency is bounded by the connection pool. Disable
CONN_MAX_AGE, use the backend’s pool, and size it for in-flight queries. -
When to deploy ASGI. If the project has no async code, WSGI is simpler and usually as fast; some purely sync codebases measure faster under ASGI, so benchmark rather than assume. Choose ASGI for long-lived connections (streaming, long-polling, WebSockets via Channels), heavy upstream I/O fan-out, or many idle connections per process.
-
CPU-bound work gains nothing from async in either mode; move it to a task queue or a process pool.
Source: Performance and Queries & the ORM
Testing async code
django.test.TestCase (and SimpleTestCase, TransactionTestCase) run async def test_… methods in an event loop,
and every test case has self.async_client, an AsyncClient whose methods are awaited. AsyncRequestFactory builds
requests for calling a view directly. The sync self.client also works with async views: it wraps them with
async_to_sync() (test test_sync_client_runs_async_views_too). Async-only helpers on the client: alogin(),
aforce_login(), alogout() and asession() (5.0). Note that setUpTestData() and setUp() stay synchronous; create
fixtures there with the sync ORM, as the AsyncTestBase class above does. The whole suite (43 tests) runs with plain
python manage.py test catalog.
$ python manage.py test catalog
Found 43 test(s).
System check identified no issues (0 silenced).
...........................................
Ran 43 tests in 3.185s
OK
Source: Testing asynchronous code
Pitfalls
-
Blocking calls in
async def—time.sleep(),requests, a sync SDK, CPU work. They freeze the event loop for every request in the process. Use async clients, orsync_to_async()for unavoidable blocking calls. -
Sync ORM in async code, including the hidden cases: lazy foreign keys, deferred fields,
len()/list()/bool()of a queryset, template rendering of a lazy queryset,request.user. All raiseSynchronousOnlyOperation. -
Expecting
gather()to parallelise queries. It doesn’t, with the default thread-sensitive adapters. -
transaction.atomic()orATOMIC_REQUESTSwith async views. Not supported; wrap the unit in a sync function. -
Forgetting
await. You get a coroutine object (and a "never awaited" warning) instead of a value. -
async_to_sync()inside a running loop raisesRuntimeError; justawait. -
Passing connections or cursors across
sync_to_async(). Encapsulate the whole database block instead. -
Sync middleware in an "async" stack. Check the
django.requestdebug log for "adapted for middleware" lines. -
Mixing sync and async handlers on one class-based view —
as_view()raisesImproperlyConfigured. -
Decorating
dispatch()without making itasync def—method_decorator()then hides the coroutine. -
Swallowing
asyncio.CancelledErroron disconnect: clean up, then re-raise. -
Fire-and-forget
asyncio.create_task()in a view for work that must happen: useaenqueue()and a real backend. -
DJANGO_ALLOW_ASYNC_UNSAFEoutside a notebook — it removes the guard rail;check --deployflags it (async.E001). -
Expecting ASGI lifespan events. Django’s handler does not implement them.
-
Benchmarking async views with WSGI
runserver— each async view gets a one-off event loop; measure under an ASGI server.
Where the books are outdated
The reference books were written for Django 1.11 to 5 and treat "async" as a synonym for Channels or Celery. Most of their advice predates the native support described here:
-
Channels or Celery as the only async answer. Native ASGI arrived in 3.0, async views and middleware in 3.1, the async ORM interface in 4.1, async streaming in 4.2, async auth, signals and test-client helpers in 5.0, async sessions in 5.1, async permission checks in 5.2,
AsyncPaginatorand the Tasks framework (withaenqueue()) in 6.0. Channels remains the tool for WebSockets and channel layers (see Channels & WebSockets); background jobs have a built-in API. -
Wrapping every query in
database_sync_to_asyncorsync_to_async. Still valid inside Channels consumers for multi-step units, but single queries now havea-methods andasync for. -
Calling the sync ORM from async code. One of the books' Channels chapters does exactly that on the event loop; on current Django it raises
SynchronousOnlyOperation, and on any version it blocks every connection in the process. Use the async interface or wrap the block. -
request.userin async code. Useawait request.auser()(5.0). -
uvicorn.workers.UvicornWorker. Deprecated by Uvicorn; useuvicorn_worker.UvicornWorkerfrom theuvicorn-workerpackage. uWSGI, which several books deploy with, is in maintenance mode; the deployment guide now documents Uvicorn, Gunicorn with Uvicorn workers, Daphne, Hypercorn and Granian for ASGI. -
asyncio.iscoroutinefunction()andasgiref.sync.markcoroutinefunction. Useinspect.iscoroutinefunction()andinspect.markcoroutinefunction()(Python 3.12+, which is the minimum for Django 6.x);asyncio.iscoroutinefunctionis deprecated since Python 3.14.
What changed recently
| Version | Change |
|---|---|
3.0 (2019) |
ASGI entry point ( |
3.1 (2020) |
Async views, async middleware ( |
4.0 (2021) |
Async API on cache backends ( |
4.1 (2022) |
Async ORM interface: |
4.2 (2023) |
|
5.0 (2023-12) |
|
5.1 (2024-08) |
Async session API ( |
5.2 LTS (2025-04) |
|
6.0 (2025-12-03) |
|
6.1 (2026-09-02) |
Async signal receivers run in an |
Rows 3.0-5.2 come from the release notes of each version; 6.0 and 6.1 from their release notes and the "Changed in Django 6.1" note in the signals topic.