Debugging & Performance

Interactive Debugging with pdb and ipdb

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 the PYTHONBREAKPOINT hook (older versions require an explicit import pdb; pdb.set_trace()).
  • Python 3.13+ if you want breakpoint() to honour pdb's new commands reuse and the improved display semantics; 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.

pdb session flow and the frame stack A breakpoint enters the command loop, which inspects the frame, navigates the call stack with up and down, and resumes with continue or step. A single pdb session breakpoint() installs trace hook command loop (Pdb) prompt resume n / s / c / until Inspect (frame stays put) list / ll - show source where - full traceback p / pp - print value display x - watch on stop execution point unchanged Frame stack (up / down) handler() <- paused here service.process() main() - up moves here
A pdb session: breakpoint() enters the command loop; inspection commands read the current frame while up and down walk the call stack; n, s, until, and c resume the interpreter.

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.

Python
# 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))
Bash
$ 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:

Bash
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.

Bash
(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.

Bash
(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.

Python
# 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
Bash
$ 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.
Bash
(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.

Bash
(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 --pdb invokes post-mortem at the moment of failure — the prompt opens in the failing frame with the exception still live. This is the workhorse for AssertionError triage; the dedicated walkthrough lives in post-mortem debugging with pdb.pm().
  • pytest --trace stops at the first line of every selected test, so you can step through setup interactively.
  • pytest --pdbcls=IPython.terminal.debugger:TerminalPdb swaps in the IPython debugger for --pdb and --trace so failures land in an ipdb-style prompt.
Bash
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.

Python
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 your breakpoint() line (or the failing line under --pdb).
  • Run import sys; p sys.gettrace() — a non-None trace function confirms the debugger is installed on the frame.
  • For pytest, run with -s once if the prompt does not appear; if -s is 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 your PYTHONBREAKPOINT setting.

Troubleshooting

SymptomRoot causeFix
(Pdb) prompt never appears under pytestOutput capturing hid stdin/stdoutUse --pdb/breakpoint() (auto-uncaptured) or add -s; avoid raw print debugging
breakpoint() does nothingPYTHONBREAKPOINT=0 set in the environmentUnset it, or set PYTHONBREAKPOINT=pdb.set_trace
*** NameError when printing a variableYou are in the wrong frameup/down to the frame that defines the name, then re-run p
step never enters a C-implemented callC functions have no Python trace eventsUse next to step over; debug the Python wrapper instead
ipdb.set_trace() raises ImportErroripdb not installed in the active venvpip install ipdb, or fall back to breakpoint()
Loop is tedious to step throughUsing next per iterationUse 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.

← Back to Systematic Debugging & Performance Profiling