Maintenance

Site is under maintenance — quizzes are still available.

Go to quizzes
How-tos

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.

July 2026 8 min read 2 views 0 hearts

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:

  1. First run with debug=True — catches most unawaited coroutines and blocking issues
  2. Add logging with timestamps — so we can see which tasks are running when
  3. Use return_exceptions with asyncio.gather — to isolate which specific task failed
  4. If stuck, use aiomonitor — to inspect running tasks and cancel deadlocked ones
  5. 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.

0 in thread

Join the discussion

Shown next to your comment.

Up to 4,000 characters

No comments yet

Be the first to leave a note — it helps the next reader.