Debugging Python Async Code Without Losing Your Mind
Learn practical techniques for debugging async Python code, including asyncio debug mode, traceback tricks, aiomonitor, and fixes for common bugs like missing await and event loop blocking.
You've written your async function, thrown in some await calls, and... nothing works. Your code doesn't crash—it just hangs there, silently waiting for something that never arrives. Welcome to debugging async Python.
The frustration is real. When I first started using asyncio for a web scraper at PythonSkillset, I spent three hours debugging a deadlock that turned out to be a missing await keyword. The code didn't throw an error—it just sat there, doing nothing, mocking me silently.
But here's the thing: async debugging isn't black magic. Once you understand how the event loop works and have the right tools, you can fix these issues quickly.
Why Async Code Is Hard to Debug
Regular Python code runs sequentially. Line A finishes, then line B starts. If something breaks, the traceback points you right to the problem.
Async code is different. When you call await some_function(), Python pauses that coroutine and lets other coroutines run. The traceback might show where the coroutine was defined, not where the problem actually occurred.
Common async bugs that will drive you nuts:
- Forgetting await before an async function call
- Creating a coroutine object but never running it
- Blocking the event loop with synchronous code
- Deadlocks from circular awaits
- Forgetting to close resources properly
Your Debugging Toolkit
1. The Obvious One: Enable asyncio Debug Mode
Python's asyncio module has a built-in debug mode that catches many common mistakes. Run your code with:
import asyncio
async def main():
# Your code here
pass
if __name__ == "__main__":
asyncio.run(main(), debug=True)
Or set the environment variable PYTHONASYNCIODEBUG=1 before running your script. This will:
- Warn about coroutines that were never awaited
- Show you when the event loop is blocked by slow code
- Log resource warnings for unclosed connections or loops
Real example from PythonSkillset's API client: We had a coroutine that fetched data but never actually executed because we forgot await. With debug mode enabled, it printed: "coroutine 'fetch_data' was never awaited" and pointed us to the exact line.
2. The Traceback Trick: asyncio.run and CancelledError
When your async code crashes, the traceback can be confusing because it shows the event loop's internal state. Here's how to get cleaner traces:
import asyncio
import traceback
async def main():
try:
await risky_async_function()
except asyncio.CancelledError:
print("Task was cancelled")
traceback.print_exc()
except Exception as e:
print(f"Error: {e}")
traceback.print_exc()
if __name__ == "__main__":
# This gives you the full traceback of the first exception
asyncio.run(main())
The trick: Use asyncio.run() instead of manually managing the event loop. It handles cleanup properly and gives you the actual traceback from inside your coroutines.
3. The Print-Debug Alternative: asyncio.gather with return_exceptions
Instead of just catching errors, collect them all:
import asyncio
async def fetch_page(url):
try:
# Simulate network request
await asyncio.sleep(1)
if "bad" in url:
raise ValueError(f"Failed to fetch {url}")
return f"Data from {url}"
except Exception as e:
return e # Return the exception instead of raising it
async def main():
urls = ["good.com", "bad.com", "good2.com"]
# With return_exceptions=True, tasks don't crash each other
results = await asyncio.gather(
*[fetch_page(url) for url in urls],
return_exceptions=True
)
for url, result in zip(urls, results):
if isinstance(result, Exception):
print(f"FAILED: {url} - {result}")
else:
print(f"OK: {url} - {result}")
asyncio.run(main())
This is incredibly useful for debugging parallel tasks. Instead of one failure killing your whole program, you see which tasks succeeded and which failed, along with their error messages.
4. The Heavy Lifter: aiomonitor
If you're dealing with complex async systems, install aiomonitor:
pip install aiomonitor
import asyncio
import aiomonitor
async def main():
# Start the monitor
with aiomonitor.start_monitor(loop=asyncio.get_event_loop()):
# Your async code runs here
tasks = [asyncio.create_task(some_work(i)) for i in range(10)]
await asyncio.gather(*tasks)
async def some_work(i):
await asyncio.sleep(10)
return i
asyncio.run(main())
While running, press Ctrl+D to enter the monitor console. You can type commands like:
- tasks — shows all running tasks
- where — shows traceback of a specific task
- signal — sends signals to tasks
- cancel — cancels stuck tasks
This is a lifesaver for debugging deadlocks in production-like environments.
The Three Most Common Async Bugs (and How to Fix Them)
Bug 1: Missing "await"
# WRONG - This creates a coroutine but never runs it
async def get_data():
return {"value": 42}
async def main():
result = get_data() # Oops, no await!
print(result) # Prints: <coroutine object get_data at 0x...>
# RIGHT
async def main():
result = await get_data()
print(result) # Prints: {'value': 42}
How to catch it: Run with debug=True—Python will warn you about unawaited coroutines.
Bug 2: Blocking the Event Loop
async def slow_operation():
# WRONG - This blocks the entire event loop!
import time
time.sleep(5) # Blocks everything for 5 seconds
return "done"
async def other_tasks():
# This won't run until slow_operation finishes
await asyncio.sleep(1)
print("I should run first!")
# RIGHT
async def slow_operation():
await asyncio.sleep(5) # Doesn't block
return "done"
How to catch it: Use asyncio.run(debug=True) — it will warn if a coroutine takes too long. Or use time.perf_counter() to measure execution time.
Bug 3: Unclosed Resources
async def fetch_data():
# WRONG - Connection not closed properly
connector = aiohttp.TCPConnector()
session = aiohttp.ClientSession(connector=connector)
response = await session.get("https://api.example.com")
return await response.json()
# session is never closed!
# RIGHT - Use async with
async def fetch_data():
async with aiohttp.ClientSession() as session:
async with session.get("https://api.example.com") as response:
return await response.json()
How to catch it: Run with warnings.filterwarnings("error") to turn resource warnings into errors.
The Real-World Debugging Flow at PythonSkillset
When something goes wrong with our async code, here's the exact process we follow:
- First run with debug=True — catches most unawaited coroutines and blocking issues
- Add logging with timestamps — so we can see which tasks are running when
- Use return_exceptions with asyncio.gather — to isolate which specific task failed
- If stuck, use aiomonitor — to inspect running tasks and cancel deadlocked ones
- Check for blocking synchronous code — especially in libraries that claim to be async-compatible
Last week, we had a pipeline that processed 1000 web pages concurrently. It kept hanging after processing 300 pages. Debug mode showed nothing. We used aiomonitor and discovered that one particularly large page was stuck on a synchronous time.sleep() call inside a third-party library we were using. Replaced it with asyncio.sleep() and the issue vanished.
Quick Reference: Your Async Debug Cheat Sheet
| Problem | Symptom | Fix |
|---|---|---|
| Missing await | Prints coroutine object | Add await before async call |
| Event loop blocked | Other tasks don't run | Replace time.sleep() with asyncio.sleep() |
| Unclosed resources | ResourceWarning | Use async with context managers |
| Deadlock | Program hangs forever | Use aiomonitor to inspect and cancel tasks |
| Exception swallowed | No error but wrong output | Use return_exceptions=True in gather |
Final Advice
Don't fight async debugging alone. The tools Python provides—debug mode, proper traceback handling, and aiomonitor—are your friends. And remember: if your async code isn't running, you probably forgot await. It happens to everyone, even experienced developers at PythonSkillset.
Learn to love asyncio.run(debug=True). It has saved me more hours than any other single line of Python. Your future self will thank you.
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.