python
async programming
non-async methods
asynchronous code
programming tips

Using async in non-async method

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

In Python, an async def function cannot be awaited directly from ordinary synchronous code. That does not mean the two styles are incompatible, but it does mean you need an explicit boundary where synchronous code hands control to the event loop.

Why the Boundary Exists

An async function returns a coroutine object. Until an event loop runs that coroutine, nothing actually happens. A normal def function has no built-in way to suspend itself with await, so the runtime forces you to choose a bridge between synchronous and asynchronous code.

That bridge is usually one of these:

  • 'asyncio.run(...) at the top level of a script or command'
  • 'asyncio.run_coroutine_threadsafe(...) when a separate loop is already running'
  • redesigning the caller so the whole call chain becomes async

The last option is often the cleanest. But in legacy code or command-line tools, a small synchronous wrapper can be perfectly reasonable.

The Simplest Pattern: asyncio.run

If the synchronous method is a top-level entry point and there is no event loop already running, asyncio.run is the standard approach.

python
1import asyncio
2
3
4async def fetch_user(user_id: int) -> dict:
5    await asyncio.sleep(0.1)
6    return {"id": user_id, "name": "Ada"}
7
8
9def get_user_sync(user_id: int) -> dict:
10    return asyncio.run(fetch_user(user_id))
11
12
13print(get_user_sync(7))

This works well for scripts, CLI commands, and small application boundaries. It creates an event loop, runs the coroutine to completion, and closes the loop.

Use this pattern only when your function truly owns the async lifecycle. If you call asyncio.run repeatedly inside hot code paths, you pay loop startup cost every time. It is fine as an application boundary, not as a general substitute for async design.

When an Event Loop Is Already Running

The most common mistake is calling asyncio.run from an environment that already has an active loop, such as Jupyter, FastAPI internals, or some GUI frameworks. In that situation Python raises an error because nested event loops are not allowed in the standard model.

If your synchronous code must submit work to a loop that already exists, run the loop elsewhere and schedule coroutines onto it safely.

python
1import asyncio
2import threading
3
4
5class AsyncBridge:
6    def __init__(self):
7        self.loop = asyncio.new_event_loop()
8        self.thread = threading.Thread(target=self._run_loop, daemon=True)
9        self.thread.start()
10
11    def _run_loop(self):
12        asyncio.set_event_loop(self.loop)
13        self.loop.run_forever()
14
15    def run(self, coro):
16        future = asyncio.run_coroutine_threadsafe(coro, self.loop)
17        return future.result()
18
19
20async def slow_add(a: int, b: int) -> int:
21    await asyncio.sleep(0.1)
22    return a + b
23
24
25bridge = AsyncBridge()
26print(bridge.run(slow_add(2, 3)))

This design keeps the loop alive in one place and lets synchronous callers submit work into it. It is more advanced than asyncio.run, but it is the correct pattern when synchronous code and long-lived async infrastructure must coexist.

Prefer Async All the Way Down When You Can

If a synchronous method is only one layer above an async call, the best fix is usually to make that method async too. Otherwise, each layer adds more blocking wrappers and the code becomes harder to reason about.

For example, this is cleaner:

python
1import asyncio
2
3
4async def load_orders():
5    await asyncio.sleep(0.1)
6    return ["A-101", "A-102"]
7
8
9async def print_orders():
10    orders = await load_orders()
11    for order in orders:
12        print(order)
13
14
15asyncio.run(print_orders())

Instead of hiding the async call inside a synchronous method, the whole path stays explicit. That improves error propagation, cancellation behavior, and readability.

Handling Results and Exceptions Correctly

Bridging sync and async code is not only about getting a value back. You also need to think about timeouts, cancellations, and exceptions.

If the async operation can fail, let that exception surface in a controlled way:

python
1import asyncio
2
3
4async def failing_call():
5    await asyncio.sleep(0.1)
6    raise RuntimeError("network problem")
7
8
9def call_sync():
10    try:
11        asyncio.run(failing_call())
12    except RuntimeError as exc:
13        print(f"Call failed: {exc}")
14
15
16call_sync()

Without that handling, a synchronous wrapper can make failures look mysterious because the real problem happened inside a coroutine the caller never sees directly.

Common Pitfalls

The most common pitfall is calling asyncio.run inside an environment that already has a running event loop. That fails immediately and is especially common in notebooks and web frameworks.

Another mistake is using blocking wrappers everywhere instead of making the surrounding code async. That throws away many of the benefits of asynchronous design.

A third issue is forgetting that coroutines do not execute until the event loop schedules them. Simply calling an async function from sync code only creates a coroutine object.

Finally, people often ignore cleanup. If you create a long-lived loop in a thread, you should also define how it shuts down when the application exits.

Summary

  • Synchronous Python code cannot use await directly, so it needs a bridge into the event loop.
  • 'asyncio.run is the standard choice for top-level sync entry points.'
  • If a loop already exists, use a thread-safe submission pattern instead of nesting asyncio.run.
  • Making more of the call chain async is often cleaner than wrapping every coroutine in sync code.
  • Handle exceptions and lifecycle cleanup explicitly when mixing sync and async styles.

Course illustration
Course illustration

All Rights Reserved.