In a Django async view looping over notifications with async for, why does reading notification.actor raise SynchronousOnlyOperation, and how do you avoid it?
answer
- relations are deferred by default
- attribute access can be a query
- load before you iterate
- deferred fields too
basics
~20 snotification.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 sIn 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 linesfrom 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
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.
Explain the descriptor mechanism behind lazy relations and deferred fields, and when to use select_related, prefetch_related or aprefetch_related_objects.
Read the traceback to tell a lazy relation from a deferred field, and design async querysets that load every relation the response reads.
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.