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
- pytest 7.0+, and optionally ipdb for the richer prompt.
- The command vocabulary from interactive debugging with pdb and ipdb.
Solution
Combine --pdb with -x so the session opens once, at the first failure:
$ 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:
$ 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:
$ pytest --pdb --pdbcls=IPython.terminal.debugger:TerminalPdb -x
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.
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:
(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:
$ 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
--pdbin 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.stdoutitself may swallow the prompt;-savoids it. --traceon 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
yieldare 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. --pdbclsneeds 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:
$ 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:
$ 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.
Related
- Interactive debugging with pdb and ipdb — the command vocabulary this prompt exposes.
- Post-mortem debugging with pdb.pm() — the same inspection without a terminal, for CI.
- Setting conditional breakpoints in pdb — stopping on the iteration that matters.
- pytest-xdist vs pytest-parallel performance comparison — why the debugger is unavailable under parallel runs.
← Back to Interactive Debugging with pdb and ipdb