skip to content

In Django's async ORM, which QuerySet calls need an a-prefixed version in an async view, and which can you chain as usual?

level: juniorimportance: must knowfreq 52%

answer

  1. does the call hit the database
  2. builders versus evaluators
  3. no afilter
  4. iteration has its own form

basics

~10 s

QuerySet builders such as filter(), exclude() and order_by() run no SQL, so they stay unchanged. Calls that execute a query use awaited a-prefixed versions: aget(), afirst(), acount(), acreate(). Iterate with async for.

solid answer

~40 s

Django splits QuerySet methods into two groups. **Methods that return new QuerySets** — `filter()`, `exclude()`, `order_by()`, `select_related()`, slicing — only build SQL, so they are safe in async code and have no async twin (there is no `afilter()`). **Methods that run a query** have an `a`-prefixed coroutine you must `await`: `aget()`, `afirst()`, `acount()`, `aexists()`, `acreate()`, `aget_or_create()`, `aupdate()`, `adelete()`, `aaggregate()` and more. Model instances add `asave()`, `adelete()` and `arefresh_from_db()`, and related managers add `aadd()`, `aset()`, `aremove()`, `aclear()`. Iteration uses `async for` (or an `async for` comprehension) instead of `for` or `list()`. Forget the `await` and you get coroutine objects where you expected rows.

code

python · 19 lines
python
from django.http import JsonResponse

from notifications.models import Notification


async def notification_summary(request):
    user = await request.auser()
    unread = Notification.objects.filter(recipient=user, read_at__isnull=True)
    count = await unread.acount()
    latest = await unread.order_by('-created_at').afirst()
    items = [
        {'id': n.pk, 'verb': n.verb}
        async for n in unread.order_by('-created_at')[:20]
    ]
    return JsonResponse({
        'unread': count,
        'latest_id': latest.pk if latest else None,
        'items': items,
    })

go deeper

for a junior

Remember the split: builders like filter() stay as they are; anything that runs a query gets an a-prefix and an await, and loops use async for.

for a middle

Explain why the split follows laziness, list the model-instance and related-manager a-methods, and spot a missing await from its symptoms.

for a senior

Catch the hidden sync evaluations in review: list(), len(), templates, lazy relations and deferred fields read after an async query.

for a principal

Set team conventions for async ORM code: one await per expression at the evaluating call, and no QuerySets leaving the view unevaluated.

## The rule: does this call touch the database? Django's QuerySet is **lazy**: building one runs no SQL; SQL runs when the QuerySet is evaluated. In async code that distinction decides everything, because running a query with the synchronous API from an async context is blocked by Django's async-safety guard. The docs give the practical rule: look up the method in the QuerySet reference, where methods are grouped into those that **return new QuerySets** (non-blocking, no async version) and those that **do not return QuerySets** (blocking, with an `a`-prefixed async version). ## The two groups | Group | Examples | In async code | |---|---|---| | Build a QuerySet | `filter()`, `exclude()`, `order_by()`, `select_related()`, `prefetch_related()`, `values()`, `annotate()`, slicing `[:20]` | Chain freely, no `await` | | Run a query, return a value | `get()`, `first()`, `last()`, `count()`, `exists()`, `contains()`, `aggregate()`, `in_bulk()`, `earliest()`, `latest()`, `explain()` | `await qs.aget()`, `afirst()`, `alast()`, `acount()`, `aexists()`, `acontains()`, `aaggregate()`, `ain_bulk()`, `aearliest()`, `alatest()`, `aexplain()` | | Write | `create()`, `get_or_create()`, `update_or_create()`, `bulk_create()`, `bulk_update()`, `update()`, `delete()` | `acreate()`, `aget_or_create()`, `aupdate_or_create()`, `abulk_create()`, `abulk_update()`, `aupdate()`, `adelete()` | | Iterate | `for n in qs`, `list(qs)`, `iterator()` | `async for n in qs`, `[n async for n in qs]`, `aiterator()` | Managers expose the same methods, so `await Notification.objects.aget(pk=pk)` works directly. ## Beyond the QuerySet - **Model instances** (since 4.2): `await obj.asave()`, `await obj.adelete()`, `await obj.arefresh_from_db()`. Since 6.0 `asave()` accepts keyword arguments only, matching `save()`. - **Related managers**: `aadd()`, `aremove()`, `aclear()`, `aset()` (added in 4.2) on many-to-many and reverse foreign-key managers, plus `acreate()`, `aget_or_create()` and `aupdate_or_create()`. - **Shortcuts** (since 5.0): `aget_object_or_404()` and `aget_list_or_404()`. - **Return values match the sync versions**: `aget_or_create()` still returns an `(object, created)` tuple, and `aget()` still raises `DoesNotExist` or `MultipleObjectsReturned`. ## Mistakes that show up in review 1. **Missing `await`.** `latest = qs.afirst()` stores a coroutine; the docs mention errors like "coroutine object has no attribute" or `<coroutine …>` strings in templates. 2. **Evaluating with sync tools.** `list(qs)`, `len(qs)`, `bool(qs)` and a plain `for` all run the query synchronously and are refused in async code. 3. **Hunting for `afilter()`.** It does not exist because `filter()` never touches the database. 4. **Lazy attributes after the fact.** A foreign key or deferred field read on an instance loaded in async code runs a hidden query; load it up front. 5. **Mixing in a transaction.** `transaction.atomic()` does not work in async code; transactional work belongs in a sync function. ## Translating a sync view line by line A sync notifications view converts mechanically once you apply the rule: 1. `Notification.objects.get(pk=pk)` becomes `await Notification.objects.aget(pk=pk)`. 2. `qs.count()` becomes `await qs.acount()`; `qs.exists()` becomes `await qs.aexists()`. 3. `for n in qs:` becomes `async for n in qs:`; `list(qs)` becomes `[n async for n in qs]`. 4. `n.save(update_fields=['read_at'])` becomes `await n.asave(update_fields=['read_at'])`. 5. `get_object_or_404(Notification, pk=pk)` becomes `await aget_object_or_404(Notification, pk=pk)`. 6. `Paginator(qs, 20)` becomes `AsyncPaginator(qs, 20)` (6.0+), whose page and count methods are awaited. Lines that only build QuerySets — `filter()`, `order_by()`, `select_related()` — are copied across untouched. ## Reading one line correctly `await user.notifications.filter(read_at__isnull=True).order_by('-created_at').afirst()` has exactly one database round-trip — at `afirst()` — and exactly one `await`, placed in front of the whole expression. Everything before it is QuerySet building. Putting the `await` in front of the expression, not in front of `filter()`, is the habit that makes async ORM code read cleanly.

  • What does aget_or_create() return in Django, and does it differ from get_or_create()?
    It returns the same `(object, created)` tuple, so you still unpack it: `obj, created = await Model.objects.aget_or_create(...)`. It wraps the synchronous `get_or_create()`, so defaults handling and the retry-on-`IntegrityError` behaviour are identical.
  • Why does passing an unevaluated QuerySet to a template from an async view fail even though no await is missing?
    The template's loop evaluates the QuerySet synchronously during rendering, on the event loop's thread, so Django's async-safety guard raises `SynchronousOnlyOperation`. Evaluate it first with an `async for` comprehension and pass the list.

saying these in an interview costs you the question

  • Every QuerySet method needs an a-prefixed version in async code, including filter().
  • list(qs) is a safe way to evaluate a QuerySet inside an async view.
  • aget_or_create() returns just the object, unlike get_or_create().
  • Model instances have no async save, so writes need raw SQL in async views.
  • Forgetting await on afirst() still returns the row, only more slowly.