Debug Async Python Without Going Crazy
Learn practical techniques to trace and fix bugs in Python async code: name tasks, enable debug mode, use gather with return_exceptions, and log coroutine entries.
Here is the article you requested.
Trace Async Bugs in Python Without Going Crazy
Debugging async code in Python feels different. Regular code stops on every line. Async code hops around. When you call await, your function pauses, another task runs, then yours comes back. If something breaks, the traceback can point to the wrong spot.
You don't have to guess. Here is how to track down bugs in async Python code without losing your mind.
Why async debugging is hard
In synchronous code, the stack trace shows exactly where your program was when it crashed. If line 10 calls line 20, and line 20 fails, Python tells you both lines.
In async code, the event loop juggles multiple tasks. A task might start, pause, and then another task runs. When an exception happens, the traceback might show where the coroutine was defined, not where the actual error occurred. You get the location of the await call, but not the context of which task triggered it.
That makes the simplest bugs—like a division by zero inside a coroutine—hard to find.
Use asyncio.run() with caution
Many people write this:
asyncio.run(main())
It works fine for simple scripts. But if main() crashes, the error message is often useless. Python 3.10 and earlier do not show you caught exceptions in tasks that are not awaited. You get a generic "Task exception was never retrieved" warning.
Better to wrap your entry point with try/except and print the full traceback:
import asyncio
async def main():
raise ValueError("Something broke")
try:
asyncio.run(main())
except Exception as e:
import traceback
traceback.print_exc()
That at least shows you where the error happened. But it does not tell you which task caused it if you have many running.
Enable debug mode in the event loop
The asyncio event loop has a debug feature. It is not on by default because it slows things down. But when you are hunting a bug, turn it on:
import asyncio
async def my_task():
await asyncio.sleep(5)
raise RuntimeError("Oops")
async def main():
loop = asyncio.get_running_loop()
loop.set_debug(True)
# set a low threshold to catch slow coroutines
loop.slow_callback_duration = 0.1
asyncio.create_task(my_task())
await asyncio.sleep(10)
asyncio.run(main())
With debug mode, Python logs every callback, every await, and every task switch. You see exactly when tasks start, pause, and resume. It is noisy, so only use it when you need it.
Track tasks with custom names
When you create a task, give it a name. That way, when it fails, you know which task is the culprit.
import asyncio
async def fetch_data(url):
# pretend this fetches something
await asyncio.sleep(1)
return url
async def main():
task = asyncio.create_task(fetch_data("https://example.com"), name="fetch-example")
await task
asyncio.run(main())
If the task raises an exception, Python shows the name in the log. Without a name, you see Task-1 or Task-2, which tells you nothing.
Use asyncio.gather() with return_exceptions=True
asyncio.gather() collects results from multiple tasks. By default, if one task fails, the others are cancelled, and you only see the first exception. That means a bug in task A can hide a bug in task B.
Switch on return_exceptions=True:
import asyncio
async def task_a():
raise ValueError("A failed")
async def task_b():
raise TypeError("B failed")
async def main():
results = await asyncio.gather(task_a(), task_b(), return_exceptions=True)
for r in results:
if isinstance(r, Exception):
print(f"Caught: {r}")
asyncio.run(main())
Now both exceptions surface. You can fix all bugs in one run instead of fixing one, rerunning, and finding the next.
Log every entry and exit in a coroutine
For really stubborn bugs, add logging inside the coroutine itself. Python's inspect module can give you the current frame, but a simpler approach is to decorate your coroutines:
import asyncio
import functools
import logging
logging.basicConfig(level=logging.DEBUG)
def trace_coro(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
logging.debug(f"Entering {func.__name__}")
try:
result = await func(*args, **kwargs)
logging.debug(f"Exiting {func.__name__}")
return result
except Exception as e:
logging.error(f"Error in {func.__name__}: {e}")
raise
return wrapper
@trace_coro
async def risky_operation():
await asyncio.sleep(0.1)
return 1 / 0
async def main():
try:
await risky_operation()
except ZeroDivisionError:
print("Caught division by zero")
asyncio.run(main())
You see exactly when the coroutine starts and when it exits. If it hangs, you see the "Entering" log without the "Exiting" log. That points right to the coroutine that is stuck.
Use asyncio.all_tasks() to dump state
If your program freezes, you want to know which tasks are still running. Call asyncio.all_tasks() to get a list of all pending tasks. Then print their names and coroutine objects:
import asyncio
async def slow():
await asyncio.sleep(100)
async def main():
asyncio.create_task(slow(), name="slow-task")
await asyncio.sleep(0.1)
for task in asyncio.all_tasks():
print(f"Task: {task.get_name()}, Coro: {task.get_coro()}")
# optionally print stack trace of each task
import traceback
traceback.print_stack(task.get_stack())
asyncio.run(main())
This is useful for debugging deadlocks or tasks that never finish. You can see the exact line of code each task is stuck on.
One more thing: watch out for unawaited coroutines
A classic async bug is forgetting await. You call a coroutine but do not await it. Python gives you a warning, but only if you run the event loop. In some environments (like Jupyter notebooks), the warning is suppressed.
To catch these, run your code with the -W flag:
python -W error::RuntimeWarning my_script.py
That turns the warning into an exception. Your program stops, and you see exactly which coroutine was left unawaited.
At PythonSkillset, we believe debugging async code does not have to be painful. Start with the basics: name your tasks, enable debug mode, and log generously. Once you get used to these patterns, async bugs become as manageable as regular ones.
Comments
Questions, corrections, and tips stay visible for everyone reading this page.
Join the discussion
No comments yet
Be the first to leave a note — it helps the next reader.