A test fails with an opaque AssertionError, or a service hangs in production and the stack trace points at a line that "cannot" be wrong. Print-statement archaeology answers one question per round-trip and pollutes the codebase; an interactive debugger answers every question at the exact moment of failure. pdb ships with CPython, attaches to any frame, and — since Python 3.7 — is reachable from a single breakpoint() call. This guide covers driving pdb and ipdb fluently: the full command vocabulary, frame navigation, and the integration points that let you drop straight into the debugger from a failing pytest run.
Prerequisites
- Python 3.7+ for the built-in
breakpoint()function and thePYTHONBREAKPOINThook (older versions require an explicitimport pdb; pdb.set_trace()). - Python 3.13+ if you want
breakpoint()to honourpdb's newcommandsreuse and the improveddisplaysemantics; everything else here works from 3.7. - ipdb (
pip install ipdb) for the IPython-backed prompt. It tracks the installed IPython version; pin both in CI if you depend on the rendering. - pytest 5.4+ for
--trace(stop at the start of each test) alongside the long-standing--pdb(post-mortem on failure).
Core concept
pdb is a thin interactive layer over sys.settrace. When you call pdb.set_trace() (or breakpoint()), the debugger installs a trace function on the current frame and hands control to a command loop bound to that frame. Every command either inspects the frame (p, pp, list, where), moves the current frame pointer up or down the call stack without resuming execution (up, down), or resumes the interpreter under a stopping condition (next, step, until, continue, return).
The critical mental model is the distinction between the execution point (where the interpreter is actually paused) and the current frame (the stack level the commands operate on). up and down move only the latter, letting you read a caller's locals without losing your place. The flow below traces a single session from breakpoint to resolution.
Step-by-step implementation
1. Enter the debugger with breakpoint()
Since Python 3.7, breakpoint() is the idiomatic entry point. It calls sys.breakpointhook(), which by default runs pdb.set_trace() but can be redirected through the PYTHONBREAKPOINT environment variable — so you never have to edit the call site to switch debuggers or disable it.
# orders.py
def apply_discount(price, percent):
factor = 1 - percent / 100
breakpoint() # interpreter pauses here; the (Pdb) prompt appears
return round(price * factor, 2)
if __name__ == "__main__":
print(apply_discount(100, 15))
$ python orders.py
> orders.py(4)apply_discount()
-> return round(price * factor, 2)
(Pdb)
To route the same call through ipdb without changing source, or to disable every breakpoint in a CI run:
PYTHONBREAKPOINT=ipdb.set_trace python orders.py # use ipdb's prompt
PYTHONBREAKPOINT=0 python orders.py # all breakpoint() calls become no-ops
2. Get your bearings: list, where, args
The first three commands at any prompt orient you. list (l) prints source around the current line; longlist (ll) prints the whole enclosing function. where (w) shows the full stack with the current frame marked. args (a) prints the arguments of the current function.
(Pdb) ll
1 def apply_discount(price, percent):
2 factor = 1 - percent / 100
3 breakpoint()
4 -> return round(price * factor, 2)
(Pdb) a
price = 100
percent = 15
(Pdb) p factor
0.85
Use p for a single value and pp (pretty-print) for nested structures — pp routes through pprint and wraps large dicts and lists across lines instead of printing one unreadable row.
3. Inspect state with p, pp, and interact
Any expression after p/pp is evaluated in the current frame, so you can call methods and build throwaway computations. For deep exploration, interact drops you into a full Python REPL seeded with the frame's globals and locals — invaluable when single expressions are not enough.
(Pdb) pp {k: type(v).__name__ for k, v in locals().items()}
{'factor': 'float', 'percent': 'int', 'price': 'int'}
(Pdb) p price * (1 - percent / 100)
85.0
(Pdb) interact
*interactive*
>>> [apply_discount(p, 10) for p in (50, 75)]
[45.0, 67.5]
>>> exit()
(Pdb)
4. Navigate frames with up, down, and where
When the bug is in a caller, walk the stack. up (u) moves the current frame pointer toward the caller; down (d) moves back toward the callee. The interpreter stays paused at the original line — you are only changing which frame p, args, and list read from.
# pipeline.py
def normalize(value):
return value.strip().lower() # AttributeError if value is not a str
def ingest(records):
return [normalize(r) for r in records]
if __name__ == "__main__":
ingest(["A", "B", 3]) # the int 3 breaks normalize
$ python -m pdb pipeline.py
(Pdb) continue
AttributeError: 'int' object has no attribute 'strip'
> pipeline.py(2)normalize()
-> return value.strip().lower()
(Pdb) p value # the offending value, in the callee frame
3
(Pdb) up # move to ingest()
> pipeline.py(5)ingest()
-> return [normalize(r) for r in records]
(Pdb) p records # now we read the caller's locals
['A', 'B', 3]
5. Control execution: n, s, c, until, return
These commands resume the interpreter, each with a different stop condition:
next(n) — run the current line; stop at the next line in this function (calls run to completion).step(s) — run until the next line anywhere, descending into called functions.continue(c) — run until the next breakpoint or program end.until(unt) — run until a line numerically greater than the current one is reached; with no argument it is the fast way to finish a loop without stepping every iteration.return(r) — run until the current function is about to return, then stop on the return.
(Pdb) n # step over, stay in this frame
(Pdb) s # step into the next call
(Pdb) until # run out the rest of the current loop
(Pdb) r # run to this function's return
(Pdb) c # resume normally
6. Watch values with display
display expr registers an expression that pdb re-evaluates after every stop, printing it only when the value changes — a built-in watch window. undisplay clears it. This is the cleanest way to track a loop variable across next steps without retyping p.
(Pdb) display factor
display factor: 0.85
(Pdb) display price * factor
display price * factor: 85.0
(Pdb) n
display price * factor: 85.0 # reprinted only if it changed
7. Drop into the debugger from pytest
pytest integrates pdb directly, and disables output capturing automatically so the prompt is visible:
pytest --pdbinvokes post-mortem at the moment of failure — the prompt opens in the failing frame with the exception still live. This is the workhorse forAssertionErrortriage; the dedicated walkthrough lives in post-mortem debugging with pdb.pm().pytest --tracestops at the first line of every selected test, so you can step through setup interactively.pytest --pdbcls=IPython.terminal.debugger:TerminalPdbswaps in the IPython debugger for--pdband--traceso failures land in an ipdb-style prompt.
pytest tests/test_orders.py::test_discount --pdb # post-mortem on failure
pytest tests/test_orders.py::test_discount --trace # break at test start
pytest --pdbcls=IPython.terminal.debugger:TerminalPdb --pdb
When the breakpoint sits inside a fixture, the same setup ordering described in mastering pytest fixtures governs which frame you land in. For setting breakpoints that only fire on a specific iteration or condition, see setting conditional breakpoints in pdb.
8. ipdb extras
ipdb is pdb with the IPython front end. Commands are identical; what you gain is tab completion on locals and attributes, syntax-highlighted source listings, multi-line paste support, and IPython magics such as %timeit at the prompt. Inside an IPython session or Jupyter, %debug opens a post-mortem ipdb prompt on the last exception, and %pdb on arms automatic post-mortem for the rest of the session.
import ipdb
def parse(payload):
ipdb.set_trace() # identical to breakpoint() but with the ipdb UI
return payload["id"]
Verification
Confirm the debugger actually stopped where you intended rather than at an import-time side effect:
- At the prompt, run
where— the arrow->must point at yourbreakpoint()line (or the failing line under--pdb). - Run
import sys; p sys.gettrace()— a non-Nonetrace function confirms the debugger is installed on the frame. - For pytest, run with
-sonce if the prompt does not appear; if-sis required, a plugin is interfering with pdb's auto-uncapture and should be reported. - Check the breakpoint hook is what you expect:
python -c "import sys; print(sys.breakpointhook)"should reflect yourPYTHONBREAKPOINTsetting.
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
(Pdb) prompt never appears under pytest | Output capturing hid stdin/stdout | Use --pdb/breakpoint() (auto-uncaptured) or add -s; avoid raw print debugging |
breakpoint() does nothing | PYTHONBREAKPOINT=0 set in the environment | Unset it, or set PYTHONBREAKPOINT=pdb.set_trace |
*** NameError when printing a variable | You are in the wrong frame | up/down to the frame that defines the name, then re-run p |
step never enters a C-implemented call | C functions have no Python trace events | Use next to step over; debug the Python wrapper instead |
ipdb.set_trace() raises ImportError | ipdb not installed in the active venv | pip install ipdb, or fall back to breakpoint() |
| Loop is tedious to step through | Using next per iteration | Use until with no arg, or a conditional breakpoint on the exit line |
Frequently Asked Questions
What is the difference between pdb and ipdb?pdb is the standard-library debugger with a bare prompt. ipdb wraps the same Pdb machinery in IPython, adding tab completion, syntax highlighting, better tracebacks, and richer introspection. Functionally the commands are identical; ipdb only improves the interactive experience.
How does breakpoint() know whether to launch pdb or ipdb?breakpoint() calls sys.breakpointhook(), which reads the PYTHONBREAKPOINT environment variable. By default it runs pdb.set_trace(); setting PYTHONBREAKPOINT=ipdb.set_trace routes to ipdb, and PYTHONBREAKPOINT=0 disables all breakpoints without touching the source.
Why does pytest swallow my breakpoint() call?
pytest captures stdout and stdin by default, which hides the debugger prompt. pytest detects pdb and disables capturing automatically for breakpoint() and --pdb, but a custom hook or the -s flag may be needed if you use a third-party debugger that pytest does not recognize.
What is the difference between the next and step commands?next (n) executes the current line and stops at the next line in the same function, treating calls as a single step. step (s) descends into the called function and stops at its first line. Use step to inspect a callee and next to stay at the current level.
Related guides
- Once a breakpoint fires too often, narrow it down with setting conditional breakpoints in pdb so the prompt only opens on the iteration that matters.
- When a crash has already happened, post-mortem debugging with pdb.pm() reopens the exact failing frame without a re-run.
- Memory growth that the debugger cannot explain is a job for memory profiling with tracemalloc.
- Debugger prompts that hang inside
awaitcalls usually trace back to event-loop issues covered in debugging async code and event loops.