When a Django async view calls a sync-only SDK through sync_to_async, what does thread_sensitive=True do, and when would you pass False?
answer
- one shared thread per request
- database connections are per thread
- calls serialize within a request
- thread-safe, no ORM, run concurrently
basics
~20 sWith thread_sensitive=True, the default, the call runs on one shared thread per request context, so thread-bound resources like Django's database connection stay valid. Pass False for thread-safe code that never touches the ORM, so calls can run concurrently.
solid answer
~40 s`sync_to_async` defaults to `thread_sensitive=True`: every thread-sensitive call runs on the same thread — under an ASGI request, a per-request worker set up by Django's `ThreadSensitiveContext`; under `async_to_sync` from a sync caller, the caller's main thread. That matters because database adapters must be used on the thread that created them, and older Django code assumes one thread per request. The cost is that thread-sensitive calls in one request **serialize**, even under `asyncio.gather`. For a sync-only SDK that is thread-safe and never touches Django's database layer, `thread_sensitive=False` runs each call on its own thread, so four gathered SDK calls overlap. Keep ORM work thread-sensitive; the docs say to size the connection pool rather than disable it.
code
python · 19 linesimport asyncio
from asgiref.sync import sync_to_async
from django.http import JsonResponse
def quote_tax(jurisdiction, net):
# stands in for a thread-safe, sync-only vendor SDK call;
# it creates its own client and never touches the ORM
return {'jurisdiction': jurisdiction, 'tax': round(net * 0.1, 2)}
quote_tax_async = sync_to_async(quote_tax, thread_sensitive=False)
async def checkout_tax(request):
lines = [('DE', 40.0), ('FR', 25.0), ('IT', 10.0), ('ES', 5.0)]
results = await asyncio.gather(*(quote_tax_async(j, n) for j, n in lines))
return JsonResponse({'taxes': results})go deeper
Know that sync_to_async has a thread_sensitive option, it defaults to True, and the ORM relies on that default.
Explain the two modes: one shared sensitive thread per request context versus a fresh thread per call, and why database connections force the default.
Decide per call when False is safe for a sync-only SDK, and explain why gathered sensitive calls serialize and why connection objects must not cross threads.
Frame thread_sensitive as a correctness budget: concurrency comes from pool sizing and isolated SDK calls, never from loosening thread affinity for ORM work.
## Two threading modes `sync_to_async(func, thread_sensitive=True)` must run `func` somewhere other than the event loop's thread. The docs define two modes: - **`thread_sensitive=True` (the default)** — the function runs **on the same thread as all other thread-sensitive functions**. When an `async_to_sync` sits above it in the stack, that is the main (calling) thread. - **`thread_sensitive=False`** — the function runs in a **brand new thread** that is closed once the call completes. Under an ASGI request Django opens a `ThreadSensitiveContext` per request, so "the same thread" means **one worker per request**, not one for the whole process. The docs spell out the consequence: within a single request, thread-sensitive calls serialize on that request's worker thread, while concurrent requests do not serialize against each other. ## Why the default is sensitive The mode exists for two Django-specific reasons: 1. **Database adapters are thread-bound.** Django keeps a database connection per thread, and a `DatabaseWrapper` created in one thread refuses to be used from another with a `DatabaseError` saying it "can only be used in that same thread". 2. **Existing sync code assumes one thread per request** — middleware that stores something on the request or in a threadlocal for the view to read later. Running all sync pieces of a request on one thread keeps both assumptions true, which is why Django's handlers use `thread_sensitive=True` when they run sync views and middleware under ASGI. ## The sync-only SDK scenario Suppose an async checkout view must call a vendor's **sync-only tax SDK** for four line-item jurisdictions. | Choice | What happens | When it is right | |---|---|---| | Default `thread_sensitive=True`, four calls under `asyncio.gather` | Calls queue on the request's single sensitive thread and run one after another | SDK is not thread-safe, keeps per-thread state, or you also do ORM work in the same helper | | `thread_sensitive=False`, four calls under `asyncio.gather` | Each call gets its own thread; the four overlap | SDK is thread-safe (or you create a client per call) and never touches Django's database layer | | One sensitive call that loops over the four jurisdictions | One crossing, sequential calls | Simplicity matters more than latency | **Check before passing `False`:** - the SDK documents thread safety, or you instantiate its client inside the wrapped function; - the wrapped function does not call the ORM, open a cursor or rely on the request's transaction; - nothing downstream relies on a threadlocal set by earlier sync code in the request. ## Things that go wrong - **Passing connection objects across.** `await sync_to_async(connection.cursor)()` resolves `connection.cursor` on the calling side, then invokes it on another thread — the docs show this failing with the thread-sharing `DatabaseError`. Wrap a helper that does all the database access instead. - **Disabling sensitivity for ORM code to "get concurrency"**. Each non-sensitive thread brings its own connection, outside the request's thread and its transaction state. The docs' advice for more concurrent requests is to **increase the connection pool size**, not to turn off `thread_sensitive`. - **Expecting overlap from gathered default calls.** They serialize; the gather only helps the non-Django awaits running beside them. - **Using `asyncio.run()` above sensitive code.** Without `async_to_sync` above it, the docs say thread-sensitive functions fall back to a single shared thread that is not the main thread. ## Where each mode runs, by caller | Caller above the wrapped function | `thread_sensitive=True` runs on | `thread_sensitive=False` runs on | |---|---|---| | ASGI request (Django's handler) | that request's per-context worker | a new thread per call | | Sync code that used `async_to_sync` | the calling (main) thread | a new thread per call | | `asyncio.run()` in a script, no `async_to_sync` | one shared thread that is not the main thread | a new thread per call | The last row explains a classic script bug: code that assumed it ran on the main thread (for example a library that insists on it) breaks when a script drives Django's async code with `asyncio.run()` instead of `async_to_sync`. ## How to explain it in one breath Thread-sensitive mode trades concurrency **within** a request for correctness of thread-bound state; turning it off is a per-call decision you justify by showing the wrapped code owns no thread-bound state and touches no Django database connection.
- Why does await sync_to_async(connection.cursor)() fail in a Django async view?`connection.cursor` is resolved in the calling async context, binding the database wrapper for that thread, and the call then runs on the sensitive worker thread. Django's thread-sharing validation raises `DatabaseError` because the wrapper was created in another thread. Wrap a helper that performs all database access so the connection is obtained on the worker thread.
- Do two concurrent ASGI requests serialize on the same thread-sensitive thread in Django?No. Django's ASGI handler enters a `ThreadSensitiveContext` for each request, so each request gets its own per-context worker. Calls within one request serialize on that worker; separate requests run their sensitive calls in parallel, limited by threads and database connections.
A shared workbench per customer order: every thread-sensitive job for that order uses the same bench and tools, one at a time; a job marked insensitive gets a fresh bench of its own, which is only safe if it never needs that order's tools.
saying these in an interview costs you the question
- thread_sensitive=True starts a new thread for every call.
- Gathering several default sync_to_async calls in one request runs them in parallel.
- Setting thread_sensitive=False is the way to speed up ORM code under load.
- All ASGI requests in a process share one thread-sensitive thread and serialize.
- Passing connection.cursor through sync_to_async is safe because it runs in a thread.