Skip to content

Django Async Views & ORM

Django can serve requests asynchronously — async views, an async ORM API, async middleware, async streaming — but it remains a framework whose core, including the ORM, is synchronous. Async Django is therefore a set of adapters between an event loop and threads, and running it well means knowing where those adapters sit. This section measures them on Django 6.1.1, Python 3.14 and PostgreSQL 17. The headline benefit is real: 1,000 requests that each waited 100 ms ran at 1,546 requests per second on one uvicorn worker, against 39 on four gunicorn sync workers. Several assumptions turned out wrong in both directions. Sync views under ASGI were not serialized — each request gets its own thread — and ran nearly as fast as async ones. The async ORM's aget matched the sync get (914–1,018 against 849 requests per second) because it is the sync ORM in a thread. A CONN_MAX_AGE carried over from WSGI exhausted PostgreSQL's 100 connections within a few hundred requests, while Django's built-in pool held 20 and raised throughput to 538. A StreamingHttpResponse with the wrong generator type buffered a two-second stream completely. And Django's LOGGING setting silenced uvicorn's own logs.

The parent section, Network I/O & Protocol Handling, covers ASGI servers, HTTP clients and database drivers in general; this topic is about the Django-specific layer on top. Each guide below takes one part of that layer, shows the measurement, and gives the configuration or code that avoids the failure it uncovered.

Architectural principles

  • Async pays for waiting. Views that call upstream services or stream benefit; fast CPU-light views do not.
  • The ORM is synchronous underneath. Async queryset methods run the sync ORM in a per-request thread, and transactions must live in one synchronous function.
  • Database connections are per thread. Under ASGI that means CONN_MAX_AGE = 0 and Django's pool, never persistent connections.
  • Match iterator types to the server. Async generators stream under ASGI; sync generators stream under WSGI; a mismatch buffers.
  • Every adapter costs a thread hop. Make middleware async-capable so the request stays on one side.
Where async Django crosses between the loop and threads 5 stacked layers. Where async Django crosses between the loop and threads ASGI server + Django handler event loop; one ThreadSensitiveContext per request middleware chain native if async-capable; thread hops if sync-only views async on the loop; sync in the request's thread ORM (aget, async for, ...) sync_to_async into the request's thread database connection per thread: pool, CONN_MAX_AGE = 0 The loop owns the request; threads own the database.

Execution model: one event loop, one thread per request

Under an ASGI server, Django's handler runs on the event loop and wraps each request in an asgiref ThreadSensitiveContext. Any thread-sensitive sync work for that request — a sync view, a sync-only middleware, every async ORM call, sync_to_async with the default thread_sensitive=True — runs on a single-thread executor created for that request. The thread names in testing made it visible: ThreadPoolExecutor-201_0, ThreadPoolExecutor-202_0 and so on, one per request. Concurrent requests therefore run their sync work in parallel threads, which is why 1,000 sleeping sync requests at 200 concurrent ran at 1,411 requests per second rather than serializing.

The same design explains the database behaviour. Django's connections are thread-local, and the threads are per request, so a persistent connection opened by one request's thread is never reused by the next; with CONN_MAX_AGE set they accumulate until PostgreSQL refuses new ones. Outside a request — management commands, scripts, tests — there is no per-request context, so thread-sensitive calls share one thread: five 200 ms queries through sync_to_async took 1.07 s in a script, and 0.25 s when each ran inside its own ThreadSensitiveContext. And because the async ORM is a thread hop around the sync ORM, it neither slows down nor speeds up individual queries; what it buys is that the event loop stays free while they run.

What the measurements showed A grid of 6 rows by 3 columns. What the measurements showed question measured answer guide do async views help? 1,546 vs 39 req/s for 100 ms waits async views do sync views serialize under ASGI? no: 1,411 req/s, a thread per request async views is the async ORM faster? no: aget 914-1,018 vs get 849 req/s async ORM persistent connections under ASGI? exhausted Postgres; pool: 20 conns, 538 req/s async ORM does StreamingHttpResponse stream? only if the generator matches the server streaming does sync middleware cost anything? about 0.18 ms per request for three middleware Django 6.1.1, Python 3.14, PostgreSQL 17, uvicorn 0.54.

Pattern catalogue

Async views for waiting, with fan-out inside the request

async def dashboard(request, user_id: int):
    async with asyncio.TaskGroup() as tg:
        profile = tg.create_task(client.get(f"{USERS}/users/{user_id}"))
        orders = tg.create_task(client.get(f"{ORDERS}/users/{user_id}/orders"))
    return JsonResponse({"profile": profile.result().json(), "orders": orders.result().json()})

A shared httpx.AsyncClient, created lazily because Django has no lifespan hooks, and a TaskGroup so the view takes as long as its slowest call. See writing async views in Django.

The async ORM, with relations loaded up front

book = await Book.objects.select_related("author").afirst()
titles = [t async for t in Book.objects.values_list("title", flat=True)[:10]]

Lazy relations raise SynchronousOnlyOperation; aiterator() on a multi-field values_list also raised in 6.1.1. See using the async Django ORM and avoiding SynchronousOnlyOperation in Django.

Transactions as one synchronous unit

@sync_to_async
def transfer(src: int, dst: int, amount: int) -> None:
    with transaction.atomic():
        ...

async with transaction.atomic() raised TypeError; there is no async transaction API in Django 6.1. Keeping the whole transaction in one function also keeps it on one thread and one connection, which is what atomic() requires; splitting it across several a-method calls would put each statement in its own thread hop with no shared transaction.

Streaming with the right generator

async def events(request):
    async def agen():
        async for event in subscribe("orders"):
            yield f"data: {event}\n\n"
    return StreamingHttpResponse(agen(), content_type="text/event-stream")   # ASGI: async generator

Under ASGI an async generator's first event arrived in 1 ms and was cancelled on disconnect; a sync one was buffered and ran to completion. See streaming responses from async Django views.

Middleware that runs natively in both modes

sync_capable = async_capable = True, markcoroutinefunction(self) when get_response is async, and an __acall__. See writing async Django middleware. Three sync-only timing middlewares cost about 0.18 ms per request on a single connection; the async-capable versions were indistinguishable from no middleware. Middleware that touches response bodies must also check response.streaming, or it can buffer the streams above.

Migrating a sync Django project

Most async Django is not written from scratch; it is an existing WSGI project that needs one streaming endpoint, or a few views that call slow services. The measurements suggest an order of work that avoids the failures above, and none of it requires converting every view.

First, change the deployment, not the code. Serve the existing project's asgi.py with uvicorn or gunicorn's UvicornWorker, with CONN_MAX_AGE = 0, the psycopg pool, and disable_existing_loggers: False. Sync views keep working — each request gets its own thread — and nothing else changes yet. Load-test the slowest endpoints before and after; for fast endpoints expect no gain, for waiting endpoints expect a large one.

Second, make the middleware stack async-capable. Django's own middleware already is; third-party and project middleware often is not. Each sync-only middleware adds thread hops to every async request, so fix these before converting views, or the conversions will look slower than they are.

Third, convert only the views that wait. Views that call upstream APIs, stream, or run several independent queries become async def; views that render templates from fast queries stay sync. Each conversion needs its relations loaded up front, its writes grouped into synchronous transaction functions, and its HTTP clients replaced with async ones.

Fourth, sweep for the hidden blocking calls. Run the test suite and staging under asyncio debug mode, watch for SynchronousOnlyOperation in logs, and check streaming endpoints' time to first byte. The mixed-mode details are in mixing sync and async views in Django.

Done in that order, every step is independently deployable and reversible, and the project never runs in a state where a configuration left over from WSGI can exhaust the database.

Moving an existing Django project to async, step by step A flow of 4 stages. Moving an existing Django project to async, step by step serve asgi.py pool, CONN_MAX_AGE=0, logs kept async-capable middleware no per-request hops convert waiting views TaskGroup, select_related sweep for blocking debug mode, TTFB, logs Each step ships on its own; no step requires converting the whole project.

Testing async views

Django's test tools support async code directly: test methods can be async def, and django.test.AsyncClient issues requests through the ASGI handler without a running server:

from django.test import AsyncClient, TestCase


class AuthorPageTests(TestCase):
    async def test_author_page_runs_queries_concurrently(self):
        client = AsyncClient()
        response = await client.get("/authors/1")
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json()["name"], "Ursula")

    async def test_export_streams(self):
        response = await AsyncClient().get("/authors/1/export")
        self.assertTrue(response.streaming)
        first = await anext(aiter(response.streaming_content))
        self.assertEqual(first, b"id,title,price\n")

Testing through AsyncClient exercises the same adapters production uses — the per-request thread context, middleware adaptation, SynchronousOnlyOperation checks — so lazy relations and sync helpers fail in tests rather than in production. Asserting response.streaming and reading only the first chunk catches a generator that was buffered. In a check against the test project, a row created with acreate inside an async TestCase method was visible to the view called through AsyncClient, the streaming assertion read the CSV header as the first chunk, and the three tests ran in 0.041 s. Reserve TransactionTestCase for tests that depend on real commits — for example, two requests that must contend for a row lock on separate connections. Broader async testing practice is in Testing Async Code.

Resource boundaries

  • Database connections: workers × pool max_size must fit under PostgreSQL's max_connections (100 by default), with room for migrations and admin sessions. CONN_MAX_AGE stays 0.
  • Threads: each concurrent request that runs sync work holds a thread for that time; at high concurrency, sync views and sync middleware cost thread stacks as well as hops.
  • Event-loop time: async views and async middleware share one loop per worker; blocking calls in them stall every request on that worker.
  • Streams: under ASGI an open stream is a suspended task; under WSGI it is a whole worker. Long-lived streams belong on ASGI.
  • Workers: for waiting-heavy traffic one uvicorn worker handled 200 concurrent slow requests; add workers for CPU, not for concurrency, as discussed in sizing uvicorn workers for async services.

Integrated production example

A settings fragment and a views module that combine the patterns: pooled connections and preserved server logging, concurrent queries inside one request, a transactional write as one synchronous unit, and a streaming export. It was run under uvicorn with an async-capable timing middleware: the author page served 487 requests per second at 50 concurrent with all three queries in a TaskGroup, the reprice endpoint updated 20,100 rows in one transaction, and the export streamed 20,101 CSV lines with its first byte after 2.9 ms and the whole body in 0.22 s:

# settings.py (fragment)
DATABASES = {"default": {
    "ENGINE": "django.db.backends.postgresql", "NAME": "app",
    "CONN_MAX_AGE": 0,                                         # ASGI: never persistent
    "OPTIONS": {"pool": {"min_size": 2, "max_size": 20}},     # per worker process
}}
LOGGING = {"version": 1, "disable_existing_loggers": False,   # keep uvicorn's loggers
           "handlers": {"console": {"class": "logging.StreamHandler"}},
           "loggers": {"django.request": {"handlers": ["console"], "level": "ERROR"}}}
MIDDLEWARE = ["myapp.middleware.TimingMiddleware"]            # sync- and async-capable


# views.py
import asyncio

from asgiref.sync import sync_to_async
from django.db import transaction
from django.db.models import F
from django.http import JsonResponse, StreamingHttpResponse

from shop.models import Author, Book


async def author_page(request, pk: int):
    async with asyncio.TaskGroup() as tg:                     # three queries, concurrently
        author = tg.create_task(Author.objects.aget(pk=pk))
        count = tg.create_task(Book.objects.filter(author_id=pk).acount())
        recent = tg.create_task(_recent_titles(pk))
    return JsonResponse({"name": author.result().name, "books": count.result(),
                         "recent": recent.result()})


async def _recent_titles(pk: int) -> list[str]:
    return [t async for t in Book.objects.filter(author_id=pk)
            .order_by("-id").values_list("title", flat=True)[:5]]


async def reprice(request, pk: int):
    updated = await _reprice_sync(pk, int(request.GET.get("by", "1")))
    return JsonResponse({"updated": updated})


@sync_to_async
def _reprice_sync(author_id: int, by: int) -> int:
    with transaction.atomic():                                # one thread, one connection
        n = Book.objects.select_for_update().filter(author_id=author_id).update(price=F("price") + by)
        Book.objects.filter(author_id=author_id, price__gt=1000).update(price=1000)
    return n


async def export(request, pk: int):
    async def rows():
        yield "id,title,price\n"
        # values(), not a multi-field values_list(): that one cannot be used with aiterator()
        async for b in Book.objects.filter(author_id=pk).values("id", "title", "price").aiterator(chunk_size=2000):
            yield f"{b['id']},{b['title']},{b['price']}\n"
    return StreamingHttpResponse(rows(), content_type="text/csv")

The first version of the export used values_list("id", "title", "price").aiterator(); it sent the CSV header and then failed with SynchronousOnlyOperation, which is how the values_list limitation was found. The TaskGroup in author_page runs three ORM calls concurrently, each in a thread hop from the request's context; with the pool, each gets its own connection.

Diagnostic hook callout

Four checks catch most async-Django problems in production:

  • Connections: SELECT count(*) FROM pg_stat_activity per application. A count that climbs with traffic and drops only on restart is the CONN_MAX_AGE leak; a flat count at the pool maximum with rising wait means the pool is undersized.
  • Event-loop lag per worker: rising lag with steady traffic points to blocking calls in async views or middleware.
  • Time to first byte on streaming endpoints: near the full duration means a generator type mismatch.
  • Adapter messages at startup with DEBUG = True: every "handler adapted for middleware" line is a thread hop on every request.

  • Threads per worker: ps -o nlwp or /proc/<pid>/status shows the worker's thread count. Under ASGI it rises with concurrent requests that run sync work — sync views, sync middleware, ORM calls — and falls when they finish; a count that only ever rises points to work that never completes, such as a sync call blocked on a dependency without a timeout.

Alert on connections above 80% of max_connections, on loop lag p99 above 50 ms, and on any streaming endpoint whose first byte exceeds a second.

Failure modes

Failure mode Root cause Detection Fix
SynchronousOnlyOperation Sync ORM call or lazy relation in async code Exception names the line a-methods, select_related, sync_to_async
PostgreSQL "too many clients" CONN_MAX_AGE > 0 under ASGI Climbing pg_stat_activity CONN_MAX_AGE = 0 with the pool
TypeError on async with transaction.atomic() No async transaction API Exception at the block Transaction inside one sync_to_async function
Stream arrives all at once Generator type does not match the server TTFB near total time; "must consume" warning Async generator under ASGI
No uvicorn logs LOGGING disabled existing loggers Empty server log disable_existing_loggers: False
Slower requests after adding middleware Sync-only middleware in an async stack "adapted for middleware" at startup Async-capable middleware
Loop stalls for seconds DJANGO_ALLOW_ASYNC_UNSAFE or blocking calls Loop lag spikes Remove the flag; move blocking calls off the loop

Frequently Asked Questions

Is async Django worth it?

For views that wait on other services or stream, yes: 100 ms waits ran at 1,546 req/s on one uvicorn worker against 39 on four gunicorn sync workers. For fast, CPU-light views throughput was the same either way.

Is the Django ORM async?

It has an async API, but in Django 6.1 each async method runs the sync ORM in a per-request thread. It keeps the event loop free; it does not make queries faster.

Do I need to change database settings for ASGI?

Yes. Set CONN_MAX_AGE to 0 and use Django's built-in PostgreSQL pool: persistent connections exhausted PostgreSQL in testing, while the pool held 20 connections and served 538 req/s.

Why does Django say 'You cannot call this from an async context'?

A blocking database call ran on the event loop thread — a sync queryset method, a lazy foreign key, or a sync helper. Use the async methods, select_related, or sync_to_async.

Can sync and async views coexist in one Django project?

Yes. Under ASGI each request's sync work runs on its own thread, so sync views still run concurrently; under WSGI async views run in a per-request event loop.