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 asgi.py application with an ASGI server (Uvicorn, Daphne, Hypercorn, Granian). Under WSGI an async view still works, but runs in its own one-off event loop and gains nothing.

ASGI: the async entry point

Call several slow services in one request

async def view plus asyncio.gather() over non-blocking clients.

Async views

Query the database from async code

The a-prefixed methods (aget, acreate, acount, …​) and async for over a queryset; transactions still need sync_to_async.

The async ORM interface

Call sync code from async code

sync_to_async(func) (thread-sensitive by default) — never call it directly.

sync_to_async and async_to_sync

Call async code from sync code

async_to_sync(coro_func).

sync_to_async and async_to_sync

Got SynchronousOnlyOperation

You called a blocking Django API inside a running event loop; wrap it, use the async variant, or move it out.

SynchronousOnlyOperation and async safety

Write middleware that does not force a thread switch

Declare sync_capable/async_capable and branch on iscoroutinefunction(get_response).

Async middleware

Users, sessions, cache, signals

request.auser(), alogin(), session.aget(), cache.aget(), Signal.asend().

Auth, sessions and cache from async code, Async signals

Stream a long response

StreamingHttpResponse with an async iterator (since 4.2).

Streaming responses with async iterators

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.

flowchart TD C[Client] --> S[ASGI server: Uvicorn / Daphne / Hypercorn / Granian] S --> H[ASGIHandler on the event loop] H --> M{Middleware chain} M -->|async-capable middleware| MA[Runs on the event loop] M -->|sync-only middleware| MS[sync_to_async: hop to a thread] MA --> V{View type} MS --> V V -->|async def view| VA[Awaited on the event loop] V -->|def view| VS[sync_to_async thread_sensitive=True: single shared thread] VA --> IO[await non-blocking I/O: HTTP, cache, async ORM wrappers] VS --> DB[Blocking ORM / I/O in the thread] IO --> R[HttpResponse] DB --> R R --> S

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.

config/asgi.py (as generated by startproject in 6.1)
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)).

Choosing a server

Django’s deployment guide documents four ASGI servers. All four served the scratch project unchanged:

Serving the project with each documented ASGI server
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:

config/settings_daphne.py — runserver served by Daphne
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 http scope only. WebSockets need Channels (see Channels & WebSockets); the ASGI lifespan protocol 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 use AppConfig.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.CancelledError inside the view (since Django 5.0; see Handling client disconnects).

  • Under ASGI, runserver is still a WSGI server unless Daphne (or another package) replaces it, so test async behaviour against a real ASGI server before trusting timings.

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.

catalog/views.py — a minimal async view and a probe of where code runs
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.

catalog/services.py — stand-in for an async HTTP client 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}
catalog/views.py — sequential awaits versus asyncio.gather
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.

catalog/views.py — blocking versus awaiting
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.

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.

catalog/views.py — cleaning up on disconnect
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).

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): ...

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").

catalog/views_cbv.py — async handlers, decorated dispatch, and a template view
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 decorate dispatch() rather than a handler, dispatch() itself must be async def (awaiting super().dispatch()), or method_decorator() cannot mark the result as a coroutine function.

  • The generic views (ListView, DetailView, the editing views) are written in sync style: their get() calls get_queryset(), get_context_data() and friends synchronously. Overriding get() as async def on them works only if nothing in that call path evaluates a queryset. Prefer a plain View with async handlers, or keep generic views sync.

  • TitlesPage passes a lazy queryset to a TemplateResponse, and it works: Django renders template responses after the view returns, inside sync_to_async, so the queryset is evaluated in a thread. The render() shortcut, by contrast, renders immediately; called inside async code with the same lazy queryset it raises SynchronousOnlyOperation (test test_render_shortcut_with_a_lazy_queryset_fails). In async views, either evaluate data first ([b async for b in qs]) or return a TemplateResponse.

catalog/tests.py — the CBV behaviour, verified
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

sync_capable

True

Can be called with a sync get_response.

async_capable

False

Can be called with an async get_response.

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.

catalog/middleware.py — a hybrid function middleware (installed in the project’s MIDDLEWARE)
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.

catalog/middleware.py — async-only class middleware, and a typical sync-only one
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

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.

catalog/tests.py — middleware modes, verified
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, list(qs), for x in qs

async for x in qs, [x async for x in qs]

get(), first(), last(), latest(), earliest()

aget(), afirst(), alast(), alatest(), aearliest()

create(), get_or_create(), update_or_create()

acreate(), aget_or_create(), aupdate_or_create()

bulk_create(), bulk_update()

abulk_create(), abulk_update()

count(), exists(), contains(), in_bulk()

acount(), aexists(), acontains(), ain_bulk()

aggregate(), iterator(), explain()

aaggregate(), aiterator(), aexplain()

update(), delete()

aupdate(), adelete()

Model.save(), delete(), refresh_from_db()

asave(), adelete(), arefresh_from_db()

Related managers: add(), create(), remove(), clear(), set()

aadd(), acreate(), aremove(), aclear(), aset()

get_object_or_404(), get_list_or_404()

aget_object_or_404(), aget_list_or_404() (5.0)

catalog/models.py — the running example’s models
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
catalog/views.py — async ORM in views
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).

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 five afirst() calls runs them one after another. The scratch project registers a SQLite sleep() function so a query takes 200 ms (a stand-in for a slow PostgreSQL query):

    catalog/slowdb.py and catalog/serial_demo.py
    import 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.20s

    Only thread_sensitive=False overlaps 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 raises SynchronousOnlyOperation. Load what you need with select_related(), prefetch_related() or arefresh_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, so models.FETCH_RAISE is useful in async code mainly to make slips visible.

  • No transactions. transaction.atomic() raises SynchronousOnlyOperation in async code, and ATOMIC_REQUESTS cannot 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 with sync_to_async.

  • No raw connections. connection.cursor() is async-unsafe; wrap the whole cursor block in a sync function.

  • Persistent connections. Disable CONN_MAX_AGE in 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.

catalog/pitfalls.py — the fixed versions of each limit (the broken ones are in SynchronousOnlyOperation and async safety)
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}))

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

sync_to_async()

sync_to_async(sync_function, thread_sensitive=True)

Returns an async function that runs sync_function in a thread. With thread_sensitive=True (the default) all thread-sensitive calls in the same context share one thread — the main thread when an async_to_sync() sits above in the stack, otherwise a single shared worker thread. With thread_sensitive=False each call may run in a different thread, in parallel.

async_to_sync()

async_to_sync(async_function, force_new_loop=False)

Returns a sync function that runs async_function on the current thread’s event loop if one is running elsewhere up the stack, or on a fresh event loop created for that one call. Calling it from a thread that is running an event loop raises RuntimeError.

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.

catalog/adapters.py — both directions, both thread modes
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():

catalog/views_services.py — a sync view calling async code
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 never async_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)() resolves connection in the loop thread and calls it in another, and fails with DatabaseError: 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_sensitive for throughput; thread_sensitive=False runs outside the request’s thread, so it cannot see the request’s open transaction or thread-local state that sync code may rely on.

catalog/tests.py — adapter behaviour, verified
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()

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.

catalog/pitfalls.py — eight ways to trigger it
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):

catalog/views.py — the bug as it appears in a real view
async def broken(request):
    title = Book.objects.get(pk=1).title  # synchronous ORM call inside async def
    return HttpResponse(title)

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).

catalog/tests.py — shared fixtures and the async-safety tests
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

request.user

await request.auser() (added by AuthenticationMiddleware)

5.0

authenticate(), login(), logout()

aauthenticate(), alogin(), alogout()

5.0

get_user(), update_session_auth_hash()

aget_user(), aupdate_session_auth_hash()

5.0

User.has_perm(), has_perms(), UserManager.create_user()

ahas_perm(), ahas_perms(), acreate_user()

5.2

login_required(), permission_required(), user_passes_test() on async views

(same decorators)

5.1

request.session.get(), ["key"] = …​, cycle_key(), …​

aget(), aset(), aupdate(), akeys(), acycle_key(), …​

5.1

cache.get(), set(), add(), delete(), incr(), …​

aget(), aset(), aadd(), adelete(), aincr(), …​

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.

catalog/views_services.py — async login, logout, current user and permissions
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.

Sessions, cache and the remaining helpers

catalog/views_services.py — sessions, cache, signals, pagination and tasks in async views
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()).

catalog/signals.py — one sync and one async receiver
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()

catalog/tests.py — auth, sessions, cache, signals, pagination and tasks, verified
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}"})

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.

catalog/views_services.py — an NDJSON feed from an async generator
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.

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.

catalog/tasks.py
from django.tasks import task


@task
def reindex_book(book_id):
    return f"indexed {book_id}"

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 time.sleep(0.5)

2.57 s

The loop is frozen; requests run one by one.

Five requests to an async view that awaits asyncio.sleep(0.5)

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, asyncio.gather()

0.22 s

The real win: fan-out inside a request.

gather() over five 200 ms afirst() queries

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.

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

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, or sync_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 raise SynchronousOnlyOperation.

  • Expecting gather() to parallelise queries. It doesn’t, with the default thread-sensitive adapters.

  • transaction.atomic() or ATOMIC_REQUESTS with 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 raises RuntimeError; just await.

  • Passing connections or cursors across sync_to_async(). Encapsulate the whole database block instead.

  • Sync middleware in an "async" stack. Check the django.request debug log for "adapted for middleware" lines.

  • Mixing sync and async handlers on one class-based view — as_view() raises ImproperlyConfigured.

  • Decorating dispatch() without making it async def — method_decorator() then hides the coroutine.

  • Swallowing asyncio.CancelledError on disconnect: clean up, then re-raise.

  • Fire-and-forget asyncio.create_task() in a view for work that must happen: use aenqueue() and a real backend.

  • DJANGO_ALLOW_ASYNC_UNSAFE outside a notebook — it removes the guard rail; check --deploy flags 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, AsyncPaginator and the Tasks framework (with aenqueue()) 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_async or sync_to_async. Still valid inside Channels consumers for multi-step units, but single queries now have a-methods and async 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.user in async code. Use await request.auser() (5.0).

  • uvicorn.workers.UvicornWorker. Deprecated by Uvicorn; use uvicorn_worker.UvicornWorker from the uvicorn-worker package. 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() and asgiref.sync.markcoroutinefunction. Use inspect.iscoroutinefunction() and inspect.markcoroutinefunction() (Python 3.12+, which is the minimum for Django 6.x); asyncio.iscoroutinefunction is deprecated since Python 3.14.

What changed recently

Table 1. Async support by version
Version Change

3.0 (2019)

ASGI entry point (asgi.py, get_asgi_application()), async safety (SynchronousOnlyOperation, DJANGO_ALLOW_ASYNC_UNSAFE).

3.1 (2020)

Async views, async middleware (sync_capable/async_capable and the decorators), AsyncClient, AsyncRequestFactory, async tests.

4.0 (2021)

Async API on cache backends (aget(), aset(), …​); the Redis cache backend.

4.1 (2022)

Async ORM interface: a-prefixed QuerySet methods and async for; async class-based view handlers.

4.2 (2023)

StreamingHttpResponse async iterators; async related-manager methods (aadd(), aset(), …​); async model methods Model.asave(), adelete() and arefresh_from_db().

5.0 (2023-12)

aauthenticate(), alogin(), alogout(), aget_user(), request.auser(); Signal.asend()/asend_robust(); aget_object_or_404()/aget_list_or_404(); test-client alogin()/aforce_login()/alogout()/asession(); aiterator() with prefetch_related(); cancellation on client disconnect.

5.1 (2024-08)

Async session API (aget(), aset(), acycle_key(), …​); login_required() and friends wrap async views.

5.2 LTS (2025-04)

acreate_user(), ahas_perm(), ahas_perms() and async methods on the auth backends.

6.0 (2025-12-03)

AsyncPaginator and AsyncPage; Task.aenqueue(); multiple Cookie headers over HTTP/2 under ASGI; minimum asgiref 3.9.1.

6.1 (2026-09-02)

Async signal receivers run in an asyncio.TaskGroup instead of asyncio.gather(); assertContains() works repeatedly on streaming responses; under ASGI, RemoteUserMiddleware again looks up custom headers with the HTTP_ prefix (restoring pre-5.2 behaviour).

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.