Django 5.1.6 asgiref sync_to_async Deadlocks With thread_sensitive=True

Event date: February 5, 2026 — django/django 5.1.6


In this post

Django 5.1.6 still ships with the long-standing thread-pool contention bug that hangs ASGI workers when sync_to_async(thread_sensitive=True) is invoked from inside asyncio.create_task() or wrapped in asyncio.wait_for(). If your async views freeze under timeout pressure, sync middleware, or nested task creation, the django 5.1 sync_to_async thread_sensitive deadlock pattern is almost certainly the cause.

  • Anchor release: Django 5.1.6 on the 5.1 series.
  • Django 5.1 depends on a recent asgiref release; the default thread_sensitive=True has held since asgiref 3.3.0.
  • Trigger pattern: a sync helper wrapped in sync_to_async is awaited from within a task spawned by asyncio.create_task, asyncio.gather, or asyncio.wait_for.
  • Symptom: the request hangs forever, or returns 504 while the worker thread sits in self._work_queue.get() indefinitely.
  • Fixes: scope thread sensitivity narrowly, drop synchronous middleware, or migrate hot paths to native async DB calls available in 5.1.
Topic diagram for Django 5.1.6 asgiref sync_to_async Deadlocks With thread_sensitive=True
Purpose-built diagram for this article — Django 5.1.6 asgiref sync_to_async Deadlocks With thread_sensitive=True.

The diagram lays out the call stack that produces the lockup. An ASGI request enters the synchronous middleware chain, which forces asgiref to wrap the entire view in a single thread-sensitive executor. Inside that view, an await asyncio.create_task(...) spawns a child coroutine that itself calls sync_to_async(handler, thread_sensitive=True). Because the outer thread is already pinned to the same executor and is currently parked on await, the inner sync call cannot acquire the only worker thread it is allowed to run on. Both sides wait for each other and never make progress.

What actually deadlocks in Django 5.1.6

The deadlock is a thread-affinity collision, not a database lock or a Python GIL artifact. thread_sensitive mode binds every sync call beneath an async-to-sync boundary to the same executor, by design. When two sync calls fight for that single thread while one of them is awaiting the other, neither can advance. Django 5.1.6 inherits this behavior verbatim from asgiref because no patch has shipped to break the affinity contract.

The mechanics live inside asgiref.sync.SyncToAsync. With thread_sensitive=True, sync calls beneath an async-to-sync boundary are routed to a single-thread executor shared across the whole nested call tree, typically anchored at Django’s ASGIHandler. Every nested sync_to_async resolves to that same single-worker pool. If the outer await is holding the only slot, the inner submission queues behind it and the future is never picked up. The blocking call inside asgiref ends up parked at self._work_queue.get() in CPython’s concurrent.futures.thread module.

More detail in distributed traces across services.

The same code paths still run safely under WSGI because there is no thread-sensitive context to bind to in the first place. That is why teams hitting this bug almost always report it after migrating to ASGI workers running under Daphne, Uvicorn, or Hypercorn. The Django 5.1 release notes do not call this out as a regression because it is not new; the regression is that more codebases have stopped using purely synchronous views and are now exercising the broken corner.

Why does thread_sensitive=True ship as the default?

The default exists to keep Django’s ORM safe. Database connections in Django are thread-local objects, and many of the C extensions used by psycopg, mysqlclient, and pyodbc require that the cursor be created and consumed on the same thread. Django’s official async support documentation states the rule plainly: thread-sensitive mode relies on async_to_sync() being above it in the stack so that all sync code runs on the same thread the connection was opened on.

Switch the default off and a freshly minted thread receives every sync_to_async call. The first ORM access on that thread implicitly opens a new connection, which is fine until you try to reuse a cursor, depend on transaction state, or hit a backend that refuses to be touched from a worker pool. Postgres will accept the new connection; SQLite will throw SQLite objects created in a thread can only be used in that same thread unless check_same_thread=False is set. So the safer default is the one that occasionally deadlocks rather than the one that silently corrupts a transaction.

More detail in debugging fundamentals.

asgiref maintainers have argued this trade-off in the issue tracker for years. The discussion in asgiref issue #482 and the original deadlock report in sync_to_async calls hang / deadlock when called in a task #348 both end at the same place: thread sensitivity is correct for the common case, dangerous for the nested-task case, and the fix has to come from the caller picking the right scope, not from flipping the default.

PyPI download statistics for django
Live data: PyPI download counts for django.

The download chart explains why this matters at scale. asgiref is pulled in transitively by Django, Channels, Starlette-adjacent shims, and FastAPI integrations that need an ASGI bridge. Even if only a small fraction of those installs trigger the nested-task path, the absolute count of affected services is large enough that the bug surfaces regularly on the Django Forum.

How do you reproduce and confirm the lockup?

The minimum reproducer is twelve lines of Django plus a single async view. Drop a sync middleware in MIDDLEWARE so that the ASGI handler is forced into thread-sensitive mode, then have the view spawn a task that itself calls a sync helper. Run the view with a timeout shorter than the helper sleep and watch the worker freeze.

import asyncio
from asgiref.sync import sync_to_async
from django.http import JsonResponse

def slow_db_call():
    # Pretend this is an ORM query that takes 2 seconds.
    import time
    time.sleep(2)
    return {"rows": 42}

async def deadlock_view(request):
    async def child():
        # thread_sensitive=True is the default - shown here for clarity.
        return await sync_to_async(slow_db_call, thread_sensitive=True)()

    # asyncio.wait_for plus thread_sensitive plus sync middleware = hang.
    result = await asyncio.wait_for(child(), timeout=1.0)
    return JsonResponse(result)

Run it with uvicorn project.asgi:application --workers 1 and hit the endpoint with curl. The request never completes, the timeout never fires cleanly, and a py-spy dump on the worker process shows the main thread parked inside concurrent.futures.thread._WorkItem.run while the asyncio loop sits at asyncio.tasks.wait_for. Both wait for the other.

A related write-up: profiling async_unsafe warnings.

Terminal animation: Django 5.1.6 asgiref sync_to_async Deadlocks With thread_sensitive=True
Watch the code run step by step.

The terminal capture walks through exactly that sequence: install Django 5.1.6 with a matching asgiref release, run the reproducer under Uvicorn, send the request, and watch the worker hang. The strace excerpt at the bottom shows futex(WAIT) calls that never return, which is the kernel-side fingerprint of two threads blocked on each other’s futures.

To confirm you are seeing this specific bug rather than a slow query or a network timeout, check three things. First, the stack trace inside py-spy contains both asgiref.sync.SyncToAsync.__call__ and asyncio.tasks._wait. Second, removing thread_sensitive=True (or all sync middleware) makes the hang vanish. Third, the issue only appears when the sync function is wrapped in a child task; calling it directly with await sync_to_async(...)() never deadlocks because the await happens on the same task that owns the outer executor.

Practical fixes that work in 5.1.6

The cleanest fix is to scope thread_sensitive=False to the calls that do not touch the ORM. CPU-bound work, third-party HTTP clients, file IO, and any helper that opens its own database connection are all safe to run on a fresh worker thread. The Django docs explicitly recommend this for long-running interfaces, and it matches what the asgiref maintainers tell people on the issue tracker.

from asgiref.sync import sync_to_async

# Safe: the helper does its own connection management.
fetch_remote = sync_to_async(requests_session.get, thread_sensitive=False)

# Keep thread_sensitive=True only when the function reuses a Django
# connection that was opened on the request thread.
load_user = sync_to_async(User.objects.get, thread_sensitive=True)

The second fix is to remove unnecessary synchronous middleware. Django’s async support page notes that a single sync middleware in the chain forces the entire request into thread-sensitive mode, even if your view and every other middleware are async. Audit MIDDLEWARE for legacy entries: old request-id loggers, custom CSRF wrappers, and pre-async authentication backends are common offenders. Replacing them with async def equivalents lets the request stay on the event loop and skip the executor entirely.

a disciplined debugging workflow goes into the specifics of this.

The third fix is to stop spawning child tasks that themselves cross the sync boundary. If you need to fan out work, prefer asyncio.gather over asyncio.create_task followed by an await, and keep each branch fully async until it has to talk to the database. When a branch must call sync code, run that sync code at the top of the coroutine rather than nesting it under another task. The deadlock pattern requires both an outer thread-sensitive context and an inner one trying to share the same single worker; collapsing the call graph removes the second context.

Benchmark: sync_to_async Latency by thread_sensitive Mode
Results across sync_to_async Latency by thread_sensitive Mode.

The latency chart compares the same handler running under three configurations on Django 5.1.6: thread_sensitive=True with sync middleware present (the deadlock-prone path), thread_sensitive=False without sync middleware, and a native async ORM path using aget(). The thread-sensitive bar shows tail-latency spikes whenever a request lands on the contested executor, while the async-native path stays flat. The middle configuration sits in between because each call pays for thread spin-up but never deadlocks.

The fourth option is to migrate hot endpoints to Django 5.1’s native async ORM. Methods such as Model.objects.aget(), aupdate_or_create(), and acreate() bypass sync_to_async entirely. They are not a drop-in replacement for every queryset operation yet, but for the high-concurrency endpoints that triggered the migration to ASGI in the first place, they remove the bridge that the deadlock depends on. The async ORM still serializes to a single connection per request, so you do not lose transactional safety.

For diagnosing live incidents, keep py-spy available on the worker container and add a periodic SIGQUIT handler that prints all thread stacks. The asgiref deadlock has a recognizable signature once you have seen it: a single Python thread parked in SyncToAsync.__call__ → loop.run_in_executor → Future.result, plus the asyncio loop thread parked in selector.select(). If your incident response runbook does not include a thread dump step for ASGI workers, this is the failure mode that will eventually convince you to add it.

You might also find inspecting live async tasks useful.

You might also find catching regressions in CI useful.

More From Author

Profiling Django 5.2 async_unsafe() Warnings With py-spy 0.4.1

Leave a Reply

Your email address will not be published. Required fields are marked *