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

The five resume commands share one starting point but stop in five different places. The diagram below pins each landing point to a concrete frame so you can pick the right command instead of stepping blindly.

Where each pdb resume command stops Paused on a line that calls parse() inside a loop, step descends into parse on line B, next runs line B fully and stops at line C, until runs the loop out to line D, return stops as the function returns on line D, and continue runs to the next breakpoint or program end. Where each resume command stops Paused at line B — (Pdb) def run(rows): A for row in rows: B item = parse(row) <- paused C results.append(item) D return results calls run to completion unless you step (s) into them; frame stays in run() except for step, which enters the callee. step (s) descends into parse() — line B's callee next (n) runs line B whole, stops at line C until runs the loop out, stops at line D return (r) runs to line D, stops as run() returns continue (c) runs to the next breakpoint or program end
From one paused line, the five resume commands stop in five distinct places: step enters the callee, next steps over it to the following line, until clears the loop, return waits for the frame to return, and continue runs on to the next breakpoint.

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. Set and manage breakpoints from the prompt

You are not limited to source-level breakpoint() calls. From any (Pdb) prompt you can plant and manage breakpoints dynamically, which keeps the codebase clean and lets you react to what you find mid-session.

  • break (b) — with no argument, list active breakpoints; with file:lineno or a function name, set one.
  • tbreak — a temporary breakpoint that removes itself the first time it is hit.
  • clear (cl) — delete a breakpoint by number, or all of them.
  • disable / enable — toggle a breakpoint without discarding its number, hit count, or attached commands.
  • ignore bpnumber count — skip the next count hits of a breakpoint, the blunt-instrument form of a hit-count condition.
Bash
(Pdb) b orders.py:5         # break at a specific line
Breakpoint 1 at orders.py:5
(Pdb) b apply_discount      # break on function entry
Breakpoint 2 at orders.py:2
(Pdb) ignore 1 3            # skip the first three crossings of bp 1
Will ignore next 3 crossings of breakpoint 1.
(Pdb) b                     # list what is armed
Num Type         Disp Enb   Where
1   breakpoint   keep yes   at orders.py:5
    ignore next 3 hits
2   breakpoint   keep yes   at orders.py:2

To make the prompt open only on the iteration that actually matters — b orders.py:5, percent > 50 attaches a condition — see setting conditional breakpoints in pdb for the full treatment of the condition command and hit-count guards.

9. Automate a breakpoint with commands

The commands command attaches a script to a breakpoint that pdb runs every time the breakpoint fires. Begin the block with silent to suppress the standard stop banner and end it with continue to keep running, and a breakpoint becomes a targeted logging probe that never pauses — zero-source print debugging with none of the cleanup.

Bash
(Pdb) b apply_discount
Breakpoint 1 at orders.py:2
(Pdb) commands 1
(com) silent
(com) p (price, percent)
(com) continue
(com) end
(Pdb) c
(100, 15)
(75, 20)

Each hit prints the arguments and resumes, so you get a trace of every call to apply_discount without stopping and without editing the function.

10. Persist aliases with .pdbrc

pdb reads ~/.pdbrc and a project-local ./.pdbrc at startup and runs each line as a command, so your most-used shortcuts survive across sessions. alias definitions live here, and %1, %2, … are positional-argument substitutions.

Plain text
# ~/.pdbrc
alias nl pp {k: v for k, v in locals().items() if not k.startswith("__")}
alias pt p type(%1)
alias src longlist

With this file in place, pt records expands to p type(records) and nl dumps the non-dunder locals as a pretty-printed dict — the two commands you retype most often, reduced to two keystrokes.

11. Attach to a hung or long-running process

The opening scenario — a service wedges and the traceback is uninformative — is the one case breakpoint() cannot reach, because you cannot edit and restart a process that is already stuck. Two techniques recover an interactive prompt from a live run:

  • python -m pdb -c continue script.py launches under pdb and, thanks to -c continue, runs normally until an unhandled exception drops you into a post-mortem prompt — no source edit, no pre-planted breakpoint.
  • Signal-triggered tracing installs a handler that calls set_trace() on a signal, so you can break into a running process on demand:
Python
import pdb
import signal

def _break_in(signum, frame):
    pdb.Pdb().set_trace(frame)   # drop to (Pdb) in the interrupted frame

signal.signal(signal.SIGUSR1, _break_in)  # then: kill -USR1 <pid> to break in

For a pure CPU stall where you only need the stack of a running process and must not stop it, a debugger is the wrong tool — reach for a sampling profiler instead, since CPU profiling with cProfile and py-spy covers py-spy dump, which attaches to a live PID and prints every thread's stack with no code change at all.

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

Most of a productive session is spent in four commands; the rest of the vocabulary exists for the cases these four cannot reach.

The four commands that carry a session A table of four core pdb commands - next, step, until and return - describing what each does to execution and the question each answers during a debugging session. The four commands that carry a session Criterion Does Answers n (next) run the line, stay here is this line correct? s (step) descend into the call is the callee correct? unt (until) run past the loop what is the state after? r (return) finish this frame what does it return?
Every session is a sequence of these four; conditional breakpoints and post-mortem entry decide only where the sequence starts.

Two habits make the sequence faster. Use display expr to pin an expression that re-prints after every step, so you watch a value evolve rather than typing p repeatedly. And set PYTHONBREAKPOINT=ipdb.set_trace in your development shell so breakpoint() opens the richer prompt without any import in the source — the same call then falls back to plain pdb in CI, where ipdb is not installed.

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