skip to content

In a Django async view looping over notifications with async for, why does reading notification.actor raise SynchronousOnlyOperation, and how do you avoid it?

level: seniorimportance: should knowfreq 36%

answer

  1. relations are deferred by default
  2. attribute access can be a query
  3. load before you iterate
  4. deferred fields too

basics

~20 s

notification.actor is a lazy foreign key: the first read runs a synchronous query, which Django refuses in async code. Load it up front with select_related('actor') or prefetch_related(), or fetch it explicitly with an awaited aget().

solid answer

~40 s

In Django, related objects are **deferred by default**: `notification.actor` holds only `actor_id` until you read the attribute, and then the descriptor runs a synchronous query. The docs call this out under async queries — if the relation was not loaded, accessing it during async evaluation attempts a blocking query and raises `SynchronousOnlyOperation`; the same applies to fields deferred with `defer()`/`only()`. The fix is to load what you will read before iterating: `select_related('actor')` for forward foreign keys, `prefetch_related()` for reverse or many-to-many relations, or `await aprefetch_related_objects(instances, ...)` (5.0+) on instances you already have. For a single object, `await User.objects.aget(pk=n.actor_id)` is explicit. There is no awaitable form of attribute access.

code

python · 17 lines
python
from django.http import JsonResponse

from notifications.models import Notification


async def notification_feed(request):
    user = await request.auser()
    qs = (
        Notification.objects.filter(recipient=user)
        .select_related('actor')
        .order_by('-created_at')[:50]
    )
    items = [
        {'id': n.pk, 'verb': n.verb, 'actor': n.actor.get_username()}
        async for n in qs
    ]
    return JsonResponse({'items': items})

go deeper

for a junior

Remember that following a foreign key can run a query, and in async code you must load it up front with select_related or prefetch_related.

for a middle

Explain the descriptor mechanism behind lazy relations and deferred fields, and when to use select_related, prefetch_related or aprefetch_related_objects.

for a senior

Read the traceback to tell a lazy relation from a deferred field, and design async querysets that load every relation the response reads.

for a principal

Treat unloaded relations as a contract gap between query and consumer, and make async views hand plain data onward instead of lazy instances.

## Why an attribute read can be a query A **foreign key** on a Django model, such as `Notification.actor`, is implemented by a **descriptor**. When a `Notification` is loaded, only the `actor_id` column is filled in. The first time code reads `notification.actor`, the descriptor notices the related object is not cached and runs a query to fetch it. In sync code that is the classic hidden query; in async code it is an error, because the query goes through the synchronous API on the event loop's thread and Django's async-safety guard raises `SynchronousOnlyOperation`. The docs' async-queries section names this directly under **"No deferred queries"**: relations are deferred by default, accessing an unloaded relation during asynchronous evaluation attempts a blocking query, and a similar limitation applies to fields deferred with `defer()` or `only()`. The docs list `fetch_mode()` (6.1) under the same caution: its modes change what an unloaded access does, but none of them turns a lazy load into an async one. ## The fixes | Situation | Fix | Notes | |---|---|---| | Forward FK or one-to-one read for every row | `.select_related('actor')` | Joined into the same query | | Reverse FK or many-to-many read for every row | `.prefetch_related('recipients')` | One extra query per relation, done during evaluation | | Instances already loaded | `await aprefetch_related_objects(items, 'actor')` | Added in 5.0 | | One related object, once | `await User.objects.aget(pk=n.actor_id)` | Explicit; uses the stored id | | Fields excluded by `only()` / `defer()` | Include them in the query | Or `await obj.arefresh_from_db(fields=[...])` | ## Reverse relations and related managers Reverse and many-to-many relations are **managers**, not objects, so they behave differently: - `user.notifications` returns a related manager; calling `.filter()` on it is QuerySet building and needs no `await`; - evaluating it needs the async API: `await user.notifications.acount()`, `async for n in user.notifications.all()`; - changing membership uses the related-manager a-methods from 4.2: `await notification.recipients.aadd(user)`, `aremove()`, `aclear()`, `aset()`, and `await user.notifications.acreate(verb='mentioned')`. ## A correct notifications feed 1. Build the QuerySet with every relation the response will read: `select_related('actor')` for the sender, `prefetch_related()` for anything many-valued. 2. Evaluate it with an `async for` comprehension into a list. 3. Build the payload from the list; every attribute read is now a cache hit. 4. Pass plain data, not model instances with unloaded relations, to templates or serializers that might dig further. ## Diagnosing it - The traceback ends in the database backend's `cursor()` or `ensure_connection()` and passes through a **related descriptor** (`__get__` on a forward-many-to-one descriptor) — that is the lazy relation. - If it passes through a deferred-attribute descriptor instead, a field was excluded by `only()` or `defer()`. - Fix the QuerySet, not the attribute read: the view is asking for data it did not load. ## Forward and reverse at a glance | Access | Kind | Async-safe form | |---|---|---| | `n.actor` | Forward FK object | Load with `select_related('actor')` first | | `n.actor_id` | Stored column | Always safe | | `user.notifications.filter(...)` | Reverse manager, builds QuerySet | Safe; evaluate with `async for` or an a-method | | `n.recipients.all()` | Many-to-many manager | Safe to build; evaluate asynchronously or prefetch | | `n.recipients.aadd(u)` | Many-to-many write | Awaited related-manager method | Keeping this table in mind turns most `SynchronousOnlyOperation` surprises into a one-line QuerySet change. ## Why there is no `await notification.actor` Attribute access in Python cannot be awaited: a descriptor's `__get__` is synchronous. Django therefore offers no async attribute access for relations; the design pushes you to load relations **as part of the query**, which is also what keeps the number of queries predictable.

  • How do you load a relation for model instances you already fetched in async code?
    Use `await aprefetch_related_objects(instances, 'actor')`, added in Django 5.0 and importable from `django.db.models`. It performs the prefetch through the async interface and fills each instance's cache, so later attribute reads are cache hits.
  • Is reading notification.actor_id safe in async code?
    Yes. `actor_id` is a concrete column value loaded with the row, so reading it runs no query. Only following the relation with `notification.actor`, when it was not loaded, triggers a lazy query.

saying these in an interview costs you the question

  • await notification.actor is the async way to follow a foreign key.
  • Relations are loaded eagerly when you iterate with async for.
  • Reading actor_id triggers the same lazy query as reading actor.
  • only() and defer() are safe to use freely in async code.
  • The fix is to set DJANGO_ALLOW_ASYNC_UNSAFE for the view.