skip to content

In a Django async view, why is wrapping each row's ORM call in sync_to_async a poor design, and how should you restructure it?

level: seniorimportance: should knowfreq 30%

answer

  1. every crossing costs a context switch
  2. microseconds times rows
  3. one crossing, whole loop
  4. transactions need one sync block

basics

~10 s

Every sync_to_async crossing pays a context switch, and per-row crossings multiply it and split the work into separate autocommit steps. Put the whole loop, and any transaction, in one sync function and cross once.

solid answer

~40 s

Each `sync_to_async` hop hands work to a thread and back. Django's docs put the per-call cost at **tens of microseconds** in-request under ASGI and **a few hundred microseconds** on the cold path used by scripts and commands — negligible once, visible when multiplied by hundreds of rows and worse under GIL contention as threads grow. Per-row wrapping also breaks atomicity: each call is its own autocommit step, and transactions do not work in async code. The docs' remedy is to move the loop **inside one crossing**: write a sync function that does the whole batch, with `transaction.atomic()` if needed, and `await sync_to_async(fn)(...)` once. The a-prefixed ORM methods are themselves one hop each, so the same reasoning applies to looping over them.

code

python · 24 lines
python
from decimal import Decimal

from asgiref.sync import sync_to_async
from django.db import transaction
from django.http import JsonResponse

from billing.models import InvoiceLine


def _reprice_lines(invoice_id, rate):
    with transaction.atomic():
        lines = list(
            InvoiceLine.objects.select_for_update().filter(invoice_id=invoice_id)
        )
        for line in lines:
            line.tax = line.net * rate
        InvoiceLine.objects.bulk_update(lines, ['tax'])
    return len(lines)


async def reprice_invoice(request, invoice_id):
    rate = Decimal(request.POST.get('rate', '0.20'))
    count = await sync_to_async(_reprice_lines)(invoice_id, rate)
    return JsonResponse({'repriced': count})

go deeper

for a junior

Remember that each sync_to_async call is a thread handoff, so wrapping inside a loop repeats that handoff for every row.

for a middle

Explain the documented cost scale, tens of microseconds in-request and a few hundred cold, and why it grows with thread contention.

for a senior

Restructure a per-row loop into one crossing with a transaction and bulk operations, and justify when several hops are still worth it.

for a principal

Set a boundary convention for the codebase: one deliberate crossing per coherent unit of sync work, reviewed like a transaction boundary.

## What a hop costs A **hop** (or crossing) is one transition between async and sync code: `sync_to_async` hands a function to a worker thread and awaits its result; `async_to_sync` does the reverse. Django's async docs give the order of magnitude: | Path | Per-call adaptation cost (Django docs) | |---|---| | In-request ASGI path, running loop reused | tens of microseconds | | Cold path: management commands, background tasks, scripts | a few hundred microseconds | Against request times measured in milliseconds, one hop is invisible. The docs add the caveat that the cost "can become so under GIL contention as the number of active threads grows". ## Why per-row wrapping hurts Consider an async view that re-prices 500 invoice lines by awaiting a wrapped `save()` for each line. Three things go wrong at once: 1. **Hops multiply** — 500 crossings, each a thread handoff and a return to the loop, instead of one. 2. **Queries multiply** — each call runs its own statement; nothing batches them. 3. **Atomicity is lost** — each wrapped call commits on its own under autocommit. Halfway through, a failure leaves 250 lines re-priced and 250 not, and `transaction.atomic()` cannot span awaits because transactions are not supported in async mode. The same applies to looping over a-prefixed ORM methods: `aget()` and friends are each implemented as one `sync_to_async` call around the sync method, so 500 `await ...aget()` calls are 500 hops. ## The restructure The docs' guidance is direct: if you are wrapping individual rows or operations in a tight loop, restructure so **the loop runs inside a single `sync_to_async`** crossing. Then: - the per-call cost is paid once and effectively disappears; - the whole batch runs on one thread and one connection; - you can wrap it in `transaction.atomic()` and use sync ORM tools such as `select_for_update()` and `bulk_update()`; - the async view stays thin: parse input, cross once, build the response. ## Wrapping sync-only libraries the same way The rule extends beyond the ORM. If an async view must call a sync-only SDK several times, write one sync helper that makes all the calls it needs and returns a result, rather than wrapping each SDK method separately. Exceptions: - calls that are **independent and thread-safe** may be worth running concurrently with `thread_sensitive=False`, one hop each, because the overlap saves more than the hops cost; - calls separated by genuinely async work (an awaited HTTP request between them) naturally need separate crossings. ## Checking whether it matters - Count crossings per request in a profile or with a debug counter; hundreds per request is the smell. - Watch thread counts and CPU under load: hop overhead shows up as GIL contention, not as slow queries. - Compare against the simplest alternative: if the endpoint is almost all sync ORM work in one crossing, a plain sync view may be clearer and just as fast. ## A before-and-after sketch 1. **Before:** the view loops over 500 lines, awaiting a wrapped `save()` per line — 500 hops, 500 `UPDATE` statements, 500 commits, no rollback on failure. 2. **After:** the view awaits one wrapped `_reprice_lines()` — one hop, one `SELECT ... FOR UPDATE`, one batched update, one commit, and a full rollback if anything raises. The second version is also easier to test: the sync function can be exercised directly in an ordinary test case without any event loop. ## The takeaway interviewers want The adapters are cheap but not free, and they are **boundaries**, not decorations. Design the boundary: one deliberate crossing around a coherent unit of sync work — the same unit that would be a transaction — rather than a wrapper around every call.

  • Does looping over await Model.objects.aget() avoid the per-hop cost?
    No. Each a-prefixed method is implemented as one `sync_to_async` call around its sync counterpart, so a loop of 500 `aget()` calls makes 500 crossings and 500 queries. Fetch the rows in one query, or move the loop into a single sync function crossed once.
  • When is it worth paying several hops instead of one in a Django async view?
    When the calls are independent and can overlap: several thread-safe, ORM-free SDK calls wrapped with `thread_sensitive=False` and gathered finish sooner than one sequential crossing. The hop cost, tens of microseconds, is small against network calls lasting hundreds of milliseconds.

saying these in an interview costs you the question

  • sync_to_async has no overhead, so wrapping every call is free.
  • One hop costs milliseconds, so even a single crossing should be avoided.
  • transaction.atomic() can span several awaited sync_to_async calls.
  • Looping over a-prefixed ORM methods avoids thread handoffs entirely.
  • Per-row crossings are fine because each row commits safely on its own.