Debugging & Performance

Dropping into pdb on Test Failure with --pdb

A test fails with assert result == expected and a diff of two large structures. Reproducing it in a REPL means recreating the fixtures by hand, which takes ten minutes and often produces a different failure. pytest --pdb skips all of that: it opens a debugger at the failing frame, with the fixtures already built and every local still bound.

Prerequisites

Solution

Combine --pdb with -x so the session opens once, at the first failure:

Bash
$ pytest tests/test_orders.py --pdb -x
______________________________ test_total ______________________________
>       assert order.total() == 300
E       assert 250 == 300

tests/test_orders.py:41: AssertionError
>>>>>>>>>>>>>>>>>>>>>> entering PDB >>>>>>>>>>>>>>>>>>>>>>
> /srv/app/tests/test_orders.py(41)test_total()
-> assert order.total() == 300
(Pdb) p order.lines
[Line(sku='A', qty=2, price=100), Line(sku='B', qty=1, price=50)]
(Pdb) p [l.qty * l.price for l in order.lines]
[200, 50]
(Pdb) u                      # move up to the fixture frame, if needed
(Pdb) c                      # continue: the run ends, teardown proceeds

The prompt opens before fixture teardown, which is the property that makes it useful: the database rows, the temporary files and the started services are all still there to inspect.

Two variants cover the rest of the cases. --trace opens the debugger at the first line of every selected test, which is the right tool when the failure is a wrong value rather than an exception and you want to step forward from the start:

Bash
$ pytest tests/test_orders.py::test_total --trace

And --pdbcls swaps the debugger implementation, so the session gets syntax highlighting, tab completion and better tracebacks:

Bash
$ pytest --pdb --pdbcls=IPython.terminal.debugger:TerminalPdb -x
Where each entry point opens the prompt A left-to-right timeline of a test with four entry points marked: trace opens at the first line, a breakpoint call opens wherever it is placed, pdb opens post-mortem at the failure, and normal teardown runs after the session ends. Where each entry point opens the prompt --trace first line of the test breakpoint() wherever you put it --pdb at the failure teardown after you continue Continuing from a --pdb prompt resumes normal reporting and teardown.
All three prompts open before fixture teardown, so the environment the test built is still intact.

Why this works

When a test raises, pytest catches the exception to build its report. With --pdb it first calls pdb.post_mortem on the exception's traceback, which reconstructs the frame stack and opens a prompt at the innermost frame. Because the exception was caught rather than propagated, none of the finally blocks in the fixture teardown chain have run yet — the stack is intact and so is everything the fixtures created. --trace uses a different mechanism, calling pdb.set_trace before the test function is invoked, which is why it steps forward rather than looking backwards.

The pytest debugger flags A table of four pytest debugging flags - pdb, trace, pdbcls and x - describing what each does and when it is the right choice. The pytest debugger flags Criterion Does Use when --pdb post-mortem at the failure an exception or assertion --trace breaks at the first line a wrong value, no exception --pdbcls=mod:Class swaps the debugger you want ipdb or pudb -x stops at the first failure always, with --pdb
The last row is the one people omit, and it is what keeps a debugging session from becoming fifty consecutive prompts.

What the prompt can and cannot see

The frame is real, so everything normally in scope is available: the test's locals, its arguments including fixture values, and every frame above it via u and d. That makes a few commands especially productive.

p and pp print and pretty-print, and pp vars() dumps the whole local namespace at once — usually the fastest first move. u walks up into the calling frame, which for a fixture-provided object means the fixture's own frame with its intermediate variables still bound. And ! prefixes an arbitrary statement, so state can be modified and the hypothesis retested inside the session:

Bash
(Pdb) pp vars()
{'order': <Order lines=2>, 'expected': 300, 'db_session': <Session ...>}
(Pdb) !order.lines[1].qty = 2        # try the hypothesis directly
(Pdb) p order.total()
300

What the prompt cannot see is anything teardown would have produced — a rollback, a flushed log, a closed connection — because none of it has happened. That is usually an advantage, but it means an assertion about post-teardown state cannot be investigated this way.

It also cannot see other workers. Under pytest-xdist the worker process has no controlling terminal, so --pdb either hangs or errors. Re-run the single failing test serially, which is the standard move and also confirms whether the failure is parallel-specific.

Making the session part of the workflow

Three settings make the debugger cheap enough to reach for routinely.

Set PYTHONBREAKPOINT so a bare breakpoint() opens the debugger you actually want, without an import in the source:

Bash
$ export PYTHONBREAKPOINT=ipdb.set_trace          # in your shell profile

Keep a project .pdbrc with aliases for the objects you inspect constantly — a session, a request, a config — so the first three commands of every session are already typed. And add --pdb to a local-only pytest alias rather than to addopts, because a debugger prompt in CI hangs the job until it times out.

For failures that only occur in CI or under parallel execution, the debugger is unavailable and the equivalent is a frame dump written to an artefact, which is covered in post-mortem debugging with pdb.pm().

Edge cases and failure modes

  • --pdb in CI hangs the job. The prompt waits for input that never arrives; guard it behind a local-only alias or an environment check.
  • Output capturing interferes with the prompt. pytest disables capture while the debugger is open, but a test that already redirected sys.stdout itself may swallow the prompt; -s avoids it.
  • --trace on a parametrized test opens once per case, which with twenty cases is twenty prompts; select a single case by node id first.
  • Continuing from the prompt still reports the failure. The debugger does not change the outcome, so the test remains red — which is correct, and occasionally surprising.
  • Fixtures with yield are mid-execution. The teardown half has not run, so an object the fixture would have closed is still open and may behave differently from a fresh one.
  • --pdbcls needs the class importable at startup. A typo produces a startup error rather than a fallback to plain pdb.

Getting to the failing test faster

Opening a debugger is cheap; getting to the right failure is where the time goes. Four pytest flags shorten that loop, and they compose.

--lf (last failed) reruns only the tests that failed in the previous run, and --ff (failed first) runs those first and then the rest. On a suite of five thousand tests with one failure, --lf turns a four-minute loop into a two-second one:

Bash
$ pytest -q                       # full run, three failures
$ pytest --lf --pdb -x            # straight to the first failure, with a prompt

--sw (stepwise) is the underrated one: it stops at the first failure and, on the next invocation, resumes from that test rather than starting over. Working through a list of failures after a refactor, it removes the re-running of everything that already passed:

Bash
$ pytest --sw -q                  # stops at failure 1
$ pytest --sw -q                  # fixes applied; resumes at failure 1, continues

-x stops at the first failure, which is what makes --pdb usable at all. And --co -q (collect only) is worth knowing for the case where the failure is in collection rather than in a test — a broken import in a conftest produces an error the debugger cannot reach, and the collection output names the file.

Combining them gives the tight loop: pytest --lf -x --pdb. Run it, fix, run it again — each iteration goes directly to the current failure with a prompt open at the frame, and no time is spent on tests that already pass. A caution about --pdb and output capturing: pytest disables capture while the prompt is open, which means anything the test logged before the failure has already been swallowed unless -s was passed. When the logs matter as much as the state, run with -s --pdb -x so the output arrives in real time and the prompt opens with the log visible above it.

Frequently Asked Questions

What does pytest --pdb actually give me? A post-mortem prompt at the frame where the test failed, with every local still bound. It is the same state pdb.pm() would show, opened automatically and before fixture teardown runs.

Why should --pdb be combined with -x? Because without it the debugger opens once per failing test, and a suite with fifty failures becomes fifty prompts. -x stops at the first failure, which is almost always the one you want.

Can I use --pdb with pytest-xdist? No. Workers have no terminal attached, so the prompt cannot be shown. Re-run the failing test without -n, or use the dump-frames approach for parallel-only failures. Does --pdb work with pytest.raises blocks? Not for the expected exception — a raise that pytest.raises catches is the test passing, so there is nothing to debug. It does open when the block fails for another reason: the wrong exception type, or no exception at all. To inspect the expected exception's state, capture it from the context manager (with pytest.raises(X) as exc_info:) and read exc_info.value and exc_info.traceback afterwards.

When each debugging entry point is the right one A stack of four situations and the matching pytest debugging entry point: an assertion failure, a wrong value with no exception, a failure in a fixture, and a failure that only happens in CI. When each debugging entry point is the right one an assertion failed --pdb: post-mortem at the failing frame wrong value, no exception --trace: step forward from the first line a fixture raised --pdb still works: the frame is in the fixture only fails in CI artefact dump, not a prompt
The last row is the boundary: a debugger needs a terminal, and CI does not have one.

← Back to Interactive Debugging with pdb and ipdb