With no async transaction.atomic in Django, which async ORM calls are still atomic on their own, and what do you do when you need more?
answer
- one statement is one unit
- transactions inside the sync call
- delete collects, then commits
- several steps need one sync block
basics
~20 sEach a-method runs its sync twin in one thread call, so anything that call does atomically stays atomic: aupdate() is one UPDATE, adelete() and aget_or_create() use transactions internally. Several steps together need one sync function with transaction.atomic(), awaited once.
solid answer
~40 sDjango does not support transactions in async code — using `transaction.atomic()` there raises `SynchronousOnlyOperation`. But each a-prefixed method runs its synchronous counterpart inside a single `sync_to_async` call, and whatever that sync method does atomically is preserved. `aupdate()` issues one `UPDATE` statement; `adelete()` runs the deletion collector, which wraps cascades and signals in `transaction.atomic(savepoint=False)`; `aget_or_create()` wraps its create in `atomic()` and retries the get on `IntegrityError`. So "mark all unread notifications read" is one safe `aupdate()`. What is not atomic is a **sequence** of awaited calls — marking read and inserting an audit row — because each commits separately under autocommit. For that, write one sync function with `transaction.atomic()` and await it once through `sync_to_async`.
code
python · 30 linesfrom asgiref.sync import sync_to_async
from django.db import transaction
from django.http import JsonResponse
from django.utils import timezone
from audit.models import AuditEvent
from notifications.models import Notification
async def mark_all_read(request):
user = await request.auser()
updated = await Notification.objects.filter(
recipient=user, read_at__isnull=True
).aupdate(read_at=timezone.now())
return JsonResponse({'updated': updated})
def _mark_read_and_audit(user_id):
with transaction.atomic():
updated = Notification.objects.filter(
recipient_id=user_id, read_at__isnull=True
).update(read_at=timezone.now())
AuditEvent.objects.create(user_id=user_id, action='notifications.read_all')
return updated
async def mark_all_read_audited(request):
user = await request.auser()
updated = await sync_to_async(_mark_read_and_audit)(user.pk)
return JsonResponse({'updated': updated})go deeper
Remember that transaction.atomic() cannot be used in async code, and that a single aupdate() is still safe on its own.
Explain why each a-method keeps the atomicity of its sync twin and which ones use transactions internally.
Decide per operation: one statement, one internally transactional a-call, or a sync function with atomic() crossed once, including row locks.
Shape async endpoints so multi-step invariants live in one sync unit of work, and make that boundary a reviewed convention.
## What "no async transactions" means The docs are plain: transactions are **not** currently supported with asynchronous queries and updates, and trying to use one raises `SynchronousOnlyOperation`. `transaction.atomic()` has no async form; entering it from async code touches the connection's autocommit and savepoint methods, which are guarded. That does not leave async code unprotected. Django runs in **autocommit** by default: each statement is its own transaction. And each a-method runs its sync twin **inside one thread call**, so any transaction the sync method opens internally still works — it opens and closes within that call. ## Which a-methods are atomic on their own | Async call | What its sync twin does | Atomic? | |---|---|---| | `qs.aupdate(read_at=now)` | One `UPDATE ... WHERE` statement | Yes — one statement | | `qs.adelete()` / `obj.adelete()` | Collector gathers cascades, sends signals, deletes inside `transaction.atomic(savepoint=False)` | Yes — one transaction | | `aget_or_create(...)` | `get()`, then `create()` inside `atomic()`, re-`get()` on `IntegrityError` | Race-safe as in sync code, given a unique constraint | | `aupdate_or_create(...)` | Runs `select_for_update().get_or_create()` and the update inside `atomic()` | Yes, as in sync code | | Two awaited calls in a row | Two separate sync calls | **No** — each commits separately | ## The notifications scenario **Mark everything read:** - `await user.notifications.filter(read_at__isnull=True).aupdate(read_at=timezone.now())` is one statement; either every matching row changes or none does. It also avoids a read-modify-write loop. **Mark read and record an audit event:** - Two awaited calls — `aupdate()` then `AuditEvent.objects.acreate(...)` — can leave rows marked read with no audit row if the second fails. - Put both in one sync function decorated or wrapped with `transaction.atomic()`, and await it once with `sync_to_async`. The whole block runs on one worker thread and one connection, so the transaction behaves exactly as in sync code. ## Steps to decide 1. Can the change be expressed as **one statement** (`aupdate()` with `F()` expressions, a filtered `adelete()`)? Then use it; it is atomic. 2. Is it a **single a-method** that is transactional internally (`aget_or_create()`, `aupdate_or_create()`, a model or QuerySet `adelete()`)? Then rely on it. 3. Does it need **several statements to succeed or fail together**? Then move them into one sync function with `transaction.atomic()` and cross once. 4. Does it need **row locks held across steps** (`select_for_update()`)? Same answer: locks only mean something inside a transaction, so the whole unit goes into the sync function. ## Reading the failure modes - **Partial writes**: two awaited calls where the second fails leave the first committed. The symptom is data that violates an invariant the code clearly intended, such as notifications marked read with no audit row. - **Lost updates**: a read-then-`asave()` pattern across two requests can overwrite a concurrent change. A single `aupdate()` with the condition in its filter, or `F()` expressions, avoids it. - **Lock scope errors**: `select_for_update()` outside a transaction raises `TransactionManagementError` in sync code; it has no meaning across awaited calls either, so the locking query belongs inside the sync unit of work. ## What not to do - Do not wrap awaited a-calls in `with transaction.atomic():` inside an async view; it raises. - Do not assume `ATOMIC_REQUESTS` covers async views; Django refuses to combine them. - Do not reach for `DJANGO_ALLOW_ASYNC_UNSAFE` to make `atomic()` work; it disables the guard, not the problem. The interview point is precise: async code has no transaction *API*, but it keeps every guarantee that a **single** synchronous ORM call already provides.
- Is aget_or_create() safe against two concurrent requests creating the same notification preference?As safe as `get_or_create()`, because it runs it: the create happens inside `atomic()`, and on `IntegrityError` it retries the `get()`. That protection depends on a unique constraint covering the lookup fields; without one, both requests can insert a row.
- Why is marking notifications read with one aupdate() better than looping with asave()?`aupdate()` issues a single `UPDATE` that is atomic and does not race with concurrent readers doing read-modify-write. A loop of `asave()` calls is one statement and one commit per row, so a failure midway leaves a partial result, and every row costs a thread handoff.
saying these in an interview costs you the question
- Wrapping awaited a-calls in transaction.atomic() works if every call is awaited.
- Without async transactions, even a single aupdate() can apply partially.
- aget_or_create() is race-prone in async code even with a unique constraint.
- Two awaited a-calls in a row commit or roll back together.
- ATOMIC_REQUESTS wraps async views in a transaction automatically.