Skip to content

Debugging Coroutines with pdb

pdb works inside coroutines, but an event loop changes what a breakpoint does. The prompt reads from the terminal synchronously on the loop's thread, so while you sit at it, nothing else in the process runs; await at the prompt needs Python 3.14's async entry point; and after a TaskGroup failure, post-mortem debugging lands in asyncio's internals rather than in the task that failed. Measured on Python 3.14 with a handler awaiting two 50 ms calls and a heartbeat task ticking every 100 ms: pausing at breakpoint() for about 1.6 s left the heartbeat silent for 1.65 s — the whole loop stopped. Typing await fetch(21) at that prompt failed with SyntaxError: 'await' outside function; after await pdb.set_trace_async() it worked, and x = await fetch(21) then p x printed 42. Stepping over an await with next let the loop run normally — heartbeat gap 0.10 s. And python -m pdb -c continue on a script whose TaskGroup raised stopped in taskgroups.py at raise BaseExceptionGroup(...), where the failing value was not in scope; pdb.post_mortem(eg.exceptions[0]) opened the child coroutine's frame with raw = 'x'. This guide shows how to use pdb effectively on async code.

Prerequisites

1. Know that a breakpoint stops the whole loop

breakpoint() in a coroutine opens the debugger in the event loop's thread. While pdb waits for input, the loop cannot run any other task, callback or timer:

async def heartbeat():
    while True:
        beats.append(time.perf_counter())
        await asyncio.sleep(0.1)

async def handler():
    a = await fetch(1)
    breakpoint()                        # the loop stops here, for every task
    b = await fetch(a)
    return b

Measured: with the prompt left waiting for about 1.6 s before continue, the largest gap between heartbeats was 1.65 s, and the handler itself took 1.65 s. Consequences follow directly: deadlines in other tasks expire the moment the loop resumes, connections time out on the other side, health checks fail, and in a server every in-flight request stalls. That makes breakpoints suitable for local reproduction, not for a shared environment. When the stall itself is what you are debugging, a debugger is the wrong tool; dump stacks instead, as in dumping stacks of a hung asyncio program.

Verify: you reproduce the problem in a process where pausing everything is acceptable.

What the loop does while pdb waits for input A sequence of 5 messages between 4 participants. What the loop does while pdb waits for input handler task pdb prompt event loop heartbeat task breakpoint() blocking read from terminal timer due: cannot run c (continue) run: 1.65 s after last tick The prompt is a blocking call on the loop thread.

2. Use set_trace_async to await at the prompt

The usual way to probe async state at a breakpoint would be to await something — fetch a row, call a client method. With breakpoint() the prompt compiles input as ordinary statements, so await is a syntax error. Python 3.14 adds an async entry point that the coroutine awaits, and its prompt accepts await expressions:

import pdb

async def handler():
    a = await fetch(1)
    await pdb.set_trace_async()         # Python 3.14+
    b = await fetch(a)
    return b

Measured at the prompt: with breakpoint(), await fetch(21) raised SyntaxError: 'await' outside function; with set_trace_async(), x = await fetch(21) succeeded and p x printed 42, and the awaited call ran on the same event loop. The loop is still blocked while pdb waits for input — a 2-second pause at the async prompt produced a 1.65 s heartbeat gap too — but each awaited expression lets the loop run until it completes. The call must be awaited; pdb.set_trace_async() without await creates a coroutine and does nothing.

Verify: at the prompt, await on one of your coroutines returns its value.

pdb in a coroutine, Python 3.14 A grid of 6 rows by 2 columns. pdb in a coroutine, Python 3.14 action result wait ~1.6 s at a breakpoint() prompt heartbeat gap 1.65 s: whole loop stopped await fetch(21) at breakpoint() prompt SyntaxError: 'await' outside function x = await fetch(21) at set_trace_async() prompt x == 42 next over b = await fetch(a) stepped to the next line; heartbeat gap 0.10 s python -m pdb -c continue, TaskGroup error stopped in taskgroups.py _aexit pdb.post_mortem(eg.exceptions[0]) stopped in parse(); raw == 'x' Heartbeat task ticks every 100 ms.

3. Step over awaits with next, not step

Inside a coroutine, next over a line containing an await runs the awaited call — including any suspension, during which the loop runs other tasks — and stops at the following line of the same coroutine. step into an await descends into the callee and, from there, into asyncio's machinery as the call suspends and resumes:

-> b = await fetch(a)
(Pdb) n
-> return b
(Pdb) p b
4

Measured: next over b = await fetch(a) stopped at return b with b == 4, and the heartbeat's largest gap stayed at 0.10 s — while stepping, the loop ran normally. Use step only when you want to enter the called coroutine itself, and until or return to get back out without walking through the event loop. Breakpoints set with b file:line work in coroutines like anywhere else, and fire in whichever task reaches the line — add a condition (b file:line, request_id == "r-42") when many tasks run the same code.

Verify: stepping over each await of a coroutine with next stays within that coroutine.

4. Post-mortem the failing task, not the TaskGroup

Running a script under python -m pdb -c continue drops into post-mortem debugging on an uncaught exception. When that exception is an ExceptionGroup from a TaskGroup, the frame pdb opens is where the group was raised:

Uncaught exception. Entering post mortem debugging
> /usr/lib/python3.14/asyncio/taskgroups.py(174)_aexit()
-> raise BaseExceptionGroup(
(Pdb) p raw
*** NameError: name 'raw' is not defined

The failing task's frames hang off the member exceptions, not the group, and pdb's exceptions command listed only the group itself. Catch the group and open post-mortem on the member you care about:

import pdb

try:
    asyncio.run(worker(["1", "2", "x"]))
except* ValueError as eg:
    pdb.post_mortem(eg.exceptions[0])        # Python 3.13+: accepts an exception

Measured: pdb opened in parse(), the coroutine that raised, and p raw printed 'x'. In tests, pytest's --pdb flag has the same limitation with exception groups; an except* block with post_mortem, or a temporary breakpoint() in the child, gets you to the right frame. The traceback printed before post-mortem already shows each member's own traceback under the group, which is often enough on its own.

Verify: post-mortem on a TaskGroup failure opens the child coroutine's frame with its local variables.

Which pdb approach for this async bug? A decision on What do you need with 4 outcomes. Which pdb approach for this async bug? What do you need? inspect state at a line breakpoint() stops every task await things at the prompt await pdb.set_trace_async() 3.14+ follow the flow next over awaits loop keeps running a TaskGroup failed post_mortem(eg.exceptions[i]) child's frame For a stuck production loop, dump stacks; do not attach a debugger.

5. Keep debugging sessions from distorting timing

Because a paused loop also pauses every timer, code that depends on time behaves differently under the debugger. Deadlines set before the pause fire immediately after it; retries see failures caused by the pause; heartbeats to other services lapse. Make the environment tolerate pauses when you debug interactively:

DEBUGGING = sys.gettrace() is not None or os.environ.get("DEBUG_SESSION") == "1"

TIMEOUT = 3600 if DEBUGGING else 2.0          # do not let the pause trip deadlines

async def call_upstream():
    async with asyncio.timeout(TIMEOUT):
        return await client.get("/status")

Keep such switches out of production code paths where possible — a test fixture or a debug configuration is a better home. With debug mode on (PYTHONASYNCIODEBUG=1), a long pause at a prompt is also reported afterwards as a slow callback, which is noise in this context. For problems that only appear under concurrency or timing pressure, a debugger is the wrong instrument altogether; per-await timing, as in tracing awaits with sys.monitoring, observes without stopping the world.

Verify: interactive sessions use long timeouts, and timing-sensitive bugs are investigated with tracing rather than breakpoints.

Verification

pdb is used effectively on async code when:

  • Breakpoints are used where stopping every task is acceptable.
  • await pdb.set_trace_async() is used when you need to await at the prompt (3.14+).
  • next steps over awaits, staying in the coroutine while the loop runs.
  • Post-mortem targets the member exception of a TaskGroup's exception group.

Diagnostic Hook: if a bug disappears under the debugger, suspect timing: the pause changed the order of tasks or let a timeout fire. Record the order of events with logging that includes task names, and compare runs with and without the breakpoint; the difference usually points at a race.

Pitfalls & edge cases

  • Breakpoints in shared environments. Measured: a 1.65 s pause for every task.
  • await at a breakpoint() prompt. Measured: SyntaxError.
  • Calling pdb.set_trace_async() without await. It does nothing.
  • Post-mortem on the group. Measured: it opened in taskgroups.py, not the failing code.

Frequently Asked Questions

Can I use pdb inside an async function?

Yes; breakpoint() works in coroutines, but it stops the whole event loop while the prompt waits: a heartbeat task paused for 1.65 s in testing.

How do I use await in the pdb prompt?

On Python 3.14, call await pdb.set_trace_async() instead of breakpoint(); x = await fetch(21) then worked at the prompt. With breakpoint(), await raised SyntaxError.

Why does pdb post-mortem stop inside asyncio/taskgroups.py?

That is where the ExceptionGroup was raised. Catch it with except* and call pdb.post_mortem(eg.exceptions[0]) to open the failing coroutine's frame.

Does stepping over an await in pdb block other tasks?

No: next over an await let the loop run normally (heartbeat gap 0.10 s) and stopped at the next line of the same coroutine.