Using the Async Django ORM¶
Django's queryset API has async counterparts — aget, acreate, afirst, acount, aexists, async for over a queryset — so async views can query the database without blocking the event loop. Under the hood, in Django 6.1, they are not a native async driver: aget is literally await sync_to_async(self.get)(*args, **kwargs), and iteration fetches rows in chunks of 100 through a thread. That design has consequences that matter more than the syntax. Measured with Django 6.1.1 on Python 3.14, PostgreSQL 17 and uvicorn: a single-row aget served 914–1,018 requests per second against 849 for the sync get — no penalty, no gain. With CONN_MAX_AGE=60, a common production setting, 300 requests at 50 concurrent left enough idle connections behind that Postgres refused new ones: 34 of those requests and all 100 of the next batch failed with FATAL: sorry, too many clients already. With CONN_MAX_AGE=0 everything succeeded at 341 requests per second; with Django's built-in psycopg pool (max_size=20) it reached 538, holding no more than 20 connections. async with transaction.atomic() raised TypeError. This guide uses the async ORM without those failures.
Prerequisites¶
- Django 5.1+ for the connection pool (tested on 6.1.1),
pip install "psycopg[binary,pool]". - Async views, from writing async views in Django.
- Connection pooling concepts, from Connection Pooling & Keep-Alive.
1. Use the a-prefixed methods and async iteration¶
Every queryset method that hits the database has an async twin, and querysets support async for:
from django.http import JsonResponse
from shop.models import Author, Book
async def author_detail(request, pk: int):
author = await Author.objects.aget(pk=pk)
count = await Book.objects.filter(author=author).acount()
recent = [b.title async for b in Book.objects.filter(author=author).order_by("-id")[:10]]
return JsonResponse({"name": author.name, "books": count, "recent": recent})
async def create_book(request):
book = await Book.objects.acreate(title="New", author_id=1)
await book.asave(update_fields=["title"])
return JsonResponse({"id": book.pk})
Methods that only build a query — filter, exclude, order_by, select_related, slicing — stay synchronous because they do not touch the database. The available async methods in Django 6.1 include aget, acreate, aget_or_create, aupdate_or_create, abulk_create, abulk_update, acount, aexists, afirst, alast, alatest, aearliest, ain_bulk, aaggregate, aupdate, adelete, aiterator, acontains and aexplain on querysets, plus asave, adelete and arefresh_from_db on instances.
Verify: no async view calls a queryset method without the a prefix or await; Django raises SynchronousOnlyOperation if one does.
2. Do not use persistent connections under ASGI¶
Django's database connections are per thread. Under ASGI each request's sync work — including every async ORM call, which runs via sync_to_async — happens on a thread created for that request. With CONN_MAX_AGE > 0, each of those threads opens a connection and leaves it open for reuse that never comes:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "app",
"CONN_MAX_AGE": 60, # fine under WSGI; leaks connections under ASGI
}
}
Measured with Postgres's default limit of 100 connections: 300 requests at 50 concurrent mostly succeeded, then requests began failing — 34 errors — and the next 100 requests all failed with sorry, too many clients already. The connections were released only when the server stopped. Django's documentation advises disabling persistent connections in async mode for this reason. Set CONN_MAX_AGE = 0, so each request opens and closes its own connection, or better, use a pool.
Verify: under sustained load, SELECT count(*) FROM pg_stat_activity stays flat rather than climbing toward max_connections.
3. Use Django's connection pool¶
Django 5.1 added built-in pooling for PostgreSQL through psycopg's pool, configured in OPTIONS:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "app",
"CONN_MAX_AGE": 0, # required with the pool
"OPTIONS": {
"pool": {"min_size": 2, "max_size": 20, "timeout": 10},
},
}
}
Measured: 538 requests per second for the single-row lookup against 341 without the pool, and pg_stat_activity showed 17–20 connections however many requests were in flight. Each request still borrows a connection on its own thread, but from a shared pool, so connections are reused across requests and capped. Size max_size from what the database can take divided by the number of worker processes; timeout bounds how long a request waits for a free connection before failing, which turns pool exhaustion into a fast error rather than a pile-up, as discussed in diagnosing connection pool exhaustion.
Verify: at peak load, total connections across all workers stay at or below workers × max_size.
4. Keep transactions in one synchronous function¶
Django 6.1 has no async transaction API. transaction.atomic is a synchronous context manager, and the async ORM methods each run in their own sync_to_async call — so there is no way to span several of them with one transaction from async code:
from asgiref.sync import sync_to_async
from django.db import transaction
async def transfer(request):
# async with transaction.atomic(): -> TypeError: 'Atomic' object does not
# support the asynchronous context manager protocol
result = await _transfer_sync(src=1, dst=2, amount=100)
return JsonResponse(result)
@sync_to_async
def _transfer_sync(src: int, dst: int, amount: int) -> dict:
with transaction.atomic():
a = Account.objects.select_for_update().get(pk=src)
b = Account.objects.select_for_update().get(pk=dst)
a.balance -= amount
b.balance += amount
a.save(update_fields=["balance"])
b.save(update_fields=["balance"])
return {"src": a.balance, "dst": b.balance}
Measured: async with transaction.atomic() raised TypeError; the same block inside a sync_to_async function ran normally. sync_to_async defaults to thread_sensitive=True, which keeps the whole function on the request's database thread and connection. Keep transactional logic in plain synchronous functions and call them as one unit; the cancellation implications of doing so are covered in making database transactions cancellation-safe.
Verify: every multi-statement write in async code is a single sync_to_async call containing transaction.atomic().
5. Fetch fewer objects, not just asynchronously¶
Async iteration moves rows through a thread in chunks of 100 by default. The cost that dominates large reads, though, is building model instances. Measured over 20,100 rows, best of three:
rows = [b async for b in Book.objects.all()] # 96 ms
rows = [b async for b in Book.objects.all().aiterator(chunk_size=2000)] # 75 ms
rows = await sync_to_async(list)(Book.objects.all()) # 111 ms
rows = [r async for r in Book.objects.values_list("id", "title")] # 9.7 ms
One combination failed outright: aiterator() on a values_list() with more than one field raised SynchronousOnlyOperation in Django 6.1.1, because that iterable runs its query as soon as iteration is requested rather than lazily; aiterator() worked on plain querysets, values() and values_list(..., flat=True), and async for directly over a multi-field values_list worked too. Larger chunks cut thread hops and helped by about a fifth; values_list was ten times faster than any way of building models, because it skips instance creation. For list endpoints and exports, select only the columns you need. For very large exports from async code, stream the rows out as they arrive rather than collecting them, as in streaming responses from async Django views; for a database-side cursor with asyncpg directly, see streaming large result sets with asyncpg cursors.
Verify: large list views use values/values_list or only(), and their latency scales with the page size, not the table size.
Verification¶
The async ORM is used safely when:
- Every query in async code uses an
a-method orasync for. CONN_MAX_AGEis 0 under ASGI, preferably with Django's psycopg pool sized per worker.- Transactions run inside one
sync_to_asyncfunction withtransaction.atomic(). - Large reads select only needed columns, or stream rather than collect.
Diagnostic Hook: chart pg_stat_activity connections per application and the pool's wait time. A connection count that climbs with traffic and drops only on restart is the persistent-connection leak; a flat count at max_size with rising wait time means the pool is the bottleneck and needs to grow or the queries need to get faster.
Pitfalls & edge cases¶
CONN_MAX_AGE > 0under ASGI. Measured: Postgres refused connections after a few hundred requests.async with transaction.atomic(). Measured:TypeError; use a sync function.- Expecting a native async driver. Django 6.1's async ORM runs queries in threads.
- Iterating full models for exports.
values_listwas ten times faster. values_list('a', 'b').aiterator(). Measured:SynchronousOnlyOperation; usevalues()or plainasync for.
Frequently Asked Questions¶
Is the Django ORM truly async?
Not in Django 6.1: the async methods wrap the sync ORM with sync_to_async, so queries run in a per-request thread. They keep the event loop free, and a one-row aget matched the sync get's throughput in testing.
Should I use CONN_MAX_AGE with Django ASGI?
No. Each request runs its database work on its own thread, and persistent connections accumulate: with CONN_MAX_AGE=60, PostgreSQL ran out of connections after a few hundred requests in testing. Use CONN_MAX_AGE=0 with Django's built-in pool.
How do I use transactions in async Django views?
Put the transaction in a synchronous function that uses transaction.atomic() and call it with sync_to_async; async with transaction.atomic() raises TypeError in Django 6.1.
How do I enable connection pooling in Django?
For PostgreSQL with psycopg 3, set OPTIONS: {'pool': {'min_size': ..., 'max_size': ...}} and CONN_MAX_AGE: 0 (Django 5.1+). In testing it raised throughput from 341 to 538 req/s and capped connections at 20.
Related¶
- Django Async — up to the topic overview.
- Avoiding SynchronousOnlyOperation in Django — the errors the async ORM guards against.
- Network I/O & Protocol Handling — the section overview.