skip to content

In Django, what do asgiref's sync_to_async() and async_to_sync() do, and when do you reach for each?

level: middleimportance: must knowfreq 55%

answer

  1. direction of the call
  2. one returns an awaitable
  3. management commands are sync
  4. threadlocals survive the hop

basics

~20 s

sync_to_async wraps a sync function so async code can await it, running it on a worker thread. async_to_sync wraps a coroutine function so sync code can call it and block for the result. Django uses both internally.

solid answer

~40 s

Both come from `asgiref.sync`, which Django installs as a dependency. `sync_to_async(func)` returns an async function: awaiting it runs `func` in a thread so the event loop keeps serving other work — you use it when an async view must call sync-only code, such as a vendor SDK or a block of ORM work. It defaults to `thread_sensitive=True`. `async_to_sync(coro_func)` goes the other way: it returns a sync function that runs the coroutine on the current thread's event loop if one exists, or on a fresh one, and blocks until it finishes — you use it from sync code such as a management command's `handle()`, a sync view, or a signal receiver that must call an async client. Threadlocals and contextvars are preserved in both directions, and both work as decorators or wrappers.

code

python · 17 lines
python
import asyncio

from asgiref.sync import async_to_sync
from django.core.management.base import BaseCommand


async def fetch_exchange_rates(currencies):
    await asyncio.sleep(0.1)  # stands in for an async API client
    return {c: 1.0 for c in currencies}


class Command(BaseCommand):
    help = 'Refresh cached exchange rates'

    def handle(self, *args, **options):
        rates = async_to_sync(fetch_exchange_rates)(['EUR', 'GBP'])
        self.stdout.write(f'fetched {len(rates)} rates')

go deeper

for a junior

Remember the direction: sync_to_async lets async code await sync code, async_to_sync lets sync code call async code.

for a middle

Explain where each runs: sync_to_async in a thread with thread_sensitive=True by default, async_to_sync on the current or a fresh event loop, blocking the caller.

for a senior

Show where Django applies the adapters itself, why the a-prefixed ORM still uses a thread, and why you group work into one crossing.

for a principal

Treat the adapters as a migration seam: decide which boundary is crossed once and deliberately, rather than letting wrappers spread through the codebase.

## Why Django needs adapters at all Django runs code in two **calling styles**. **Sync** code is ordinary functions that block their thread until done; most of Django — the ORM, forms, template rendering, most third-party packages — is written this way. **Async** code is coroutine functions that `await` and run on an event loop. The two cannot call each other directly: a sync function cannot `await`, and a coroutine that calls blocking code stalls every other coroutine on its loop. Django's answer is `asgiref`, a package maintained by the Django project and installed with Django. Its `asgiref.sync` module provides two adapters, and Django's own request handlers use them to run sync views under ASGI and async views under WSGI. ## The two adapters | Adapter | You have | You want | What it returns | |---|---|---|---| | `sync_to_async(func, thread_sensitive=True)` | async code | to call a sync function | an async function to `await` | | `async_to_sync(coro_func, force_new_loop=False)` | sync code | to call a coroutine function | a sync function that blocks for the result | ### `sync_to_async` - Runs the sync function **in a thread**, never on the event loop's thread, so the loop keeps serving other coroutines while it runs. - By default (`thread_sensitive=True`) it runs on one shared thread together with every other thread-sensitive call; with `thread_sensitive=False` it runs on a thread of its own. - It can wrap a function once (`get_rate_async = sync_to_async(get_rate)`) or decorate one (`@sync_to_async`). ### `async_to_sync` - Runs the coroutine on the event loop for the current thread if there is one; otherwise it starts a loop for this single invocation and shuts it down afterwards, much like `asyncio.run()`. - Either way the coroutine executes on a **different thread** from the caller, which blocks until the result is ready. - It is the adapter that makes `thread_sensitive=True` work properly further down: with `async_to_sync` above it in the stack, thread-sensitive functions run back on the calling (main) thread. Both adapters preserve **threadlocals and contextvars** across the boundary in both directions, so things like the active translation or a request-scoped variable are still visible on the other side. ## When you reach for each 1. **An async view needs a sync-only library** — wrap the call (or a helper that makes several calls) with `sync_to_async` and `await` it. 2. **An async view needs ORM work the async API cannot express**, such as a transaction — put the whole block in one sync function and cross once with `sync_to_async`. 3. **Sync code needs an async client** — a management command, a sync view, a sync signal receiver or a test helper calls `async_to_sync(fetch)(...)`. 4. **Library code must serve both worlds** — expose one implementation and adapt it at the edge, rather than duplicating logic. ## Rules of thumb - Do not call `async_to_sync` from code that is already running inside an event loop on the same thread; `asgiref` refuses, and the right move there is simply to `await` the coroutine. - Do not call a sync function directly from async code "because it is fast"; if it touches Django's database layer it will raise `SynchronousOnlyOperation`, and if it blocks it stalls the loop. - Wrap **functions**, not objects: pass a helper that does the work, rather than resolved database handles, across the boundary. - Each crossing has a cost (small, but real), so group work into one crossing rather than wrapping every row. ## Where Django already does it for you - Under **ASGI**, sync views are wrapped with `sync_to_async(thread_sensitive=True)` by the request handler. - Under **WSGI**, async views are wrapped with `async_to_sync`. - The a-prefixed ORM methods such as `aget()` are themselves implemented by awaiting `sync_to_async` around the sync method. Knowing this explains why the same view code runs under either server, and why async ORM calls still occupy a thread underneath.

  • What happens if a Django async view calls a sync SDK function directly, without sync_to_async?
    If the function only does blocking I/O, it runs on the event loop's thread and stalls every other coroutine on that loop until it returns. If it touches Django's database layer, Django's async-safety guard raises `SynchronousOnlyOperation` instead. Either way the fix is to `await sync_to_async(func)(...)`.
  • Why can a sync view under WSGI call async_to_sync safely but an async view cannot?
    In a sync view under WSGI there is no running event loop on that thread, so `async_to_sync` can start one for the call. An async view is already running on a loop; `asgiref` refuses to block that loop's thread waiting on itself, so the async view should simply `await` the coroutine.

saying these in an interview costs you the question

  • sync_to_async runs the sync function on the event loop's own thread.
  • async_to_sync is the right way to call a coroutine from an async view.
  • Threadlocals and contextvars are lost when crossing with the asgiref adapters.
  • asgiref is a third-party add-on you must install separately for Django async.
  • A sync function that is quick can be called directly from async code safely.