Bridging Sync and Async
asyncio.to_thread runs sync code on a thread without blocking the event loop. asyncio.run_coroutine_threadsafe runs async code from a thread. asgiref.sync provides sync_to_async / async_to_sync helpers. Mixing the two worlds is messy; isolate the boundary and pick one direction per layer.
What it is
Python has two concurrency models in active use: threads (the threading module, plus everything sync) and asyncio coroutines. They coexist in many applications: a Django view runs sync, but it calls a microservice via aiohttp; a FastAPI app runs async, but the database driver is sync.
When code needs to cross the boundary, a bridge is required. Doing it wrong leads to frozen event loops, deadlocks, "no running event loop" errors, and silently-not-running coroutines.
The problem in each direction
Sync code inside async: calling time.sleep(1) or requests.get(...) or any blocking sync function from inside an async coroutine blocks the entire event loop. Every other coroutine, every other request, stops making progress for the duration. The fix: offload to a thread with asyncio.to_thread(func, *args).
Async code inside sync: calling an async function from sync code returns a coroutine object without running anything. To actually execute it, run it on an event loop. If no loop is running, asyncio.run(coro) creates one and tears it down. If a loop is running on another thread, asyncio.run_coroutine_threadsafe(coro, loop) schedules it and returns a Future.
The right tools
asyncio.to_thread(func, *args) is the simplest sync-in-async bridge. It runs the sync call on the loop's default executor (a ThreadPoolExecutor) and returns an awaitable.
loop.run_in_executor(executor, func, *args) is the lower-level form. Use it to control which executor (a process pool for CPU-bound work, a custom thread pool with different sizing).
asyncio.run(coro) is the sync entry point. Creates a loop, runs the coroutine to completion, tears down. Use it at the top of if __name__ == "__main__":. Don't call it from inside an already-running loop; that raises.
asyncio.run_coroutine_threadsafe(coro, loop) is the threadsafe call: schedule a coroutine on a loop running on another thread, get back a concurrent.futures.Future.
asgiref.sync.sync_to_async and async_to_sync are the convenient wrappers used by Django and Channels. They handle loop creation under the hood.
anyio is a runtime-agnostic wrapper (works on asyncio and trio) with cleaner bridging primitives. Worth it for libraries that want to support both runtimes.
Picking the boundary
The cleanest applications pick a direction per layer.
The web layer (FastAPI, Starlette, aiohttp) is async. Routes are async, middleware is async, framework hooks are async.
The data layer is async when feasible (asyncpg, motor, aioredis). Sync drivers wrapped in to_thread work, but every call pays the thread overhead.
CPU-bound utility code is sync, called from async via to_thread. If it is heavy, ProcessPoolExecutor via run_in_executor.
The mistake is ping-ponging across the boundary inside one request: async-to-sync-to-async-to-sync. Each crossing costs a thread (or worse, blocks). Pick a layer and stay there.
What to memorise
Two facts are enough to write correct bridging code most of the time:
- From async code, blocking calls go through
await asyncio.to_thread(blocking_func, *args). - From sync code, async calls go through
asyncio.run(coro)(one-shot) orasyncio.run_coroutine_threadsafe(coro, loop)(when a loop is already running elsewhere).
Everything else (anyio, asgiref, custom executors) is layered on top.
Primitives by language
- asyncio.to_thread(func, *args), run sync in a thread
- loop.run_in_executor(None, func, *args), same, lower-level
- asyncio.run_coroutine_threadsafe(coro, loop), call async from a thread
- asgiref.sync.sync_to_async / async_to_sync (Django, Channels)
- anyio (cross-runtime sync/async helpers)
Implementation
psycopg2 is a sync library. Calling cursor.execute directly from an async function blocks the event loop. asyncio.to_thread runs the sync call on a thread from the default executor and returns an awaitable. The event loop stays responsive.
1 import asyncio
2 import psycopg2
3
4 def sync_query(sql, params):
5 with psycopg2.connect(...) as conn:
6 with conn.cursor() as cur:
7 cur.execute(sql, params)
8 return cur.fetchall()
9
10 async def get_users(limit):
11 # WRONG: blocks the event loop
12 # return sync_query("SELECT * FROM users LIMIT %s", (limit,))
13
14 # RIGHT: runs the sync call on a thread, awaits the result
15 return await asyncio.to_thread(
16 sync_query, "SELECT * FROM users LIMIT %s", (limit,)
17 )An async function with a sync caller (a Django view, a CLI, a library callback). If the loop is already running on another thread, run_coroutine_threadsafe schedules the coroutine and returns a concurrent.futures.Future. The sync caller blocks on .result().
1 import asyncio
2 import threading
3
4 # Set up a long-running event loop on a background thread
5 loop = asyncio.new_event_loop()
6 threading.Thread(target=loop.run_forever, daemon=True).start()
7
8 async def fetch(url):
9 # ... aiohttp etc ...
10 return f"data from {url}"
11
12 def sync_caller(url):
13 # Schedule the coroutine on the running loop, block until done
14 fut = asyncio.run_coroutine_threadsafe(fetch(url), loop)
15 return fut.result(timeout=10)
16
17 # Sync code:
18 print(sync_caller("https://api.example.com"))For a sync entry point that just wants to invoke an async pipeline once, asyncio.run is the cleanest bridge. It creates a loop, runs the coroutine to completion, and tears down. Only call from sync code, never from inside an existing loop.
1 import asyncio
2
3 async def pipeline():
4 async with aiohttp.ClientSession() as session:
5 return await fetch_all(session, urls)
6
7 def main():
8 # Sync entry point
9 results = asyncio.run(pipeline())
10 for r in results:
11 print(r)
12
13 if __name__ == "__main__":
14 main()Django views can be async or sync. asgiref provides decorators to convert between them. sync_to_async wraps a sync callable so it can be awaited (runs on a thread). async_to_sync wraps an async callable so it can be called sync. The library handles the loop lifecycle.
1 from asgiref.sync import sync_to_async, async_to_sync
2
3 def sync_db_query(uid):
4 return User.objects.get(id=uid)
5
6 async def async_view(request, uid):
7 # Run the sync ORM call on a thread, await the result
8 user = await sync_to_async(sync_db_query)(uid)
9 return JsonResponse({"name": user.name})
10
11 def sync_caller(uid):
12 # Run an async coroutine from sync code
13 fetch = async_to_sync(some_async_func)
14 return fetch(uid)Key points
- •Calling sync blocking code from async (without to_thread) freezes the event loop.
- •Calling async code from sync (without proper bridging) raises RuntimeError or silently never runs.
- •asyncio.to_thread offloads the sync call to the default thread pool. Returns awaitable.
- •From a non-event-loop thread, asyncio.run_coroutine_threadsafe schedules a coroutine on the loop and returns a concurrent.futures.Future.
- •When in doubt, isolate the boundary: a layer is fully sync OR fully async, never mixed.
Follow-up questions
▸Why not just call sync code from async?
▸What thread runs asyncio.to_thread tasks?
▸When is anyio preferable over asyncio directly?
▸What is the boundary to aim for?
Gotchas
- !Calling time.sleep / requests.get / sync DB drivers from async blocks the event loop
- !asyncio.run inside a running loop raises RuntimeError; only call from sync code
- !asyncio.run_coroutine_threadsafe needs a loop already running on another thread
- !to_thread workers come from a fixed pool; thousands of concurrent to_thread calls queue up
- !asgiref helpers create per-call loops if needed; expensive in tight loops