PYTHON / TESTING AND TOOLING
Debugging with pdb and breakpoint()
Stop a running Python program at a chosen line with breakpoint(), inspect frames and locals in pdb, and control or disable it via PYTHONBREAKPOINT.
What you will learn
- Drop breakpoint() into a function and inspect its live locals in pdb
- Choose between n, s, c, r, u and d to move through frames deliberately
- Disable or redirect every breakpoint() with the PYTHONBREAKPOINT variable
- Debug a crash after the fact with python -m pdb -c continue script.py
Understanding Debugging with pdb and breakpoint()
A debugger does not read your code, it borrows your program's frames. When execution reaches breakpoint(), Python calls sys.breakpointhook(), which by default calls pdb.set_trace(); pdb then installs a trace function and hands you a prompt sitting inside the frame that called it. Everything you type is evaluated in that frame's real namespace, so p cleaned shows the value the running program actually has, not the value you assume it has.
The movement commands map onto the call stack rather than onto text. n (next) runs the current line to completion, including any function it calls; s (step) enters the called function so you can watch it work; r (return) finishes the current function and stops just before it hands the value back; c (continue) releases the program until the next breakpoint. u and d move your viewpoint up and down the existing stack without executing anything, which is how you inspect the caller's variables while stopped deep inside a helper. w prints the whole stack, and l shows source around the current line.
breakpoint() exists instead of import pdb; pdb.set_trace() because of the hook indirection. Since the call goes through sys.breakpointhook, the environment variable PYTHONBREAKPOINT controls what happens: unset means pdb, PYTHONBREAKPOINT=0 makes every breakpoint() a no-op, and PYTHONBREAKPOINT=web_pdb.set_trace routes them to another debugger. You can therefore change debugging behaviour for a whole run without editing a single line, and you can also assign sys.breakpointhook yourself to log frame state instead of stopping.
import sys
def parse_price(text):
cleaned = text.strip().replace("$", "")
breakpoint() # real pdb would stop the program here
return float(cleaned)
def show_stop():
frame = sys._getframe(1)
print("stopped in", frame.f_code.co_name, "at line", frame.f_lineno)
print("locals:", dict(frame.f_locals))
sys.breakpointhook = show_stop
print(parse_price(" $19.50 "))breakpoint() suspends the program inside a real stack frame and routes through sys.breakpointhook, so the debugger you get is configurable from outside the code.
Worked examples
Turning every breakpoint() off
PYTHONBREAKPOINT=0 makes breakpoint() return immediately, so the program runs straight through.
import os
os.environ["PYTHONBREAKPOINT"] = "0"
def double(n):
breakpoint()
return n * 2
print(double(21))
print("finished without stopping")Example explained
Line 1os.environ assignment reaches the C environment, and the default hook reads PYTHONBREAKPOINT on every call.
Line 2The value "0" makes sys.__breakpointhook__ return None without importing pdb at all.
Line 3double(21) therefore runs its body normally and returns 42.
Line 4In real use you set this on the command line: PYTHONBREAKPOINT=0 python app.py.
Post-mortem: locals at the moment of the crash
Walking the traceback to the deepest frame shows exactly what pdb.post_mortem() would put you in front of.
import sys
def parse_row(row):
name, qty = row.split(",")
return name, int(qty)
def load(rows):
return [parse_row(r) for r in rows]
try:
load(["apple,3", "pear,x"])
except ValueError:
tb = sys.exc_info()[2]
while tb.tb_next is not None:
tb = tb.tb_next
print("crashed in", tb.tb_frame.f_code.co_name)
print("locals at crash:", dict(tb.tb_frame.f_locals))Example explained
Line 1int("x") raises ValueError, and the traceback keeps every Python frame alive that was on the stack.
Line 2tb_next chains outward-to-inward, so looping to the last link lands on the innermost Python frame, parse_row.
Line 3The locals show qty was the string 'x', which is the actual cause rather than a guess.
Line 4pdb.post_mortem(tb) drops you into that same frame interactively, as does python -m pdb -c continue script.py.
Important notes
pdb evaluates expressions in the stopped frame, so p on a property or a method call runs that code and any side effects it has.
breakpoint() has existed since Python 3.7; ruff's flake8-debugger rule (T100) will flag leftover calls, which is the cheap way to keep them out of commits.
Common mistakes
Committing a breakpoint() call: under pytest the run stops on stdin and fails with 'reading from stdin while output is captured', and in production it hangs the process instead of crashing visibly.
Typing n to inspect a helper function's internals; n runs the whole call in one go, so the bug happens invisibly and you need s instead.
Typing a bare variable name that collides with a command, so c, n, l or b continue, step or list instead of printing; use p c or !c to force evaluation.
Assuming pdb is read-only: !total = 0 or p items.pop() really mutates the program, and execution continues with the changed state.
Try it yourself
Change, predict, then run
Write a function that sums a list of numbers, then assign sys.breakpointhook to a function that prints dict(sys._getframe(1).f_locals) and call breakpoint() inside the loop, so you see the running total change on each iteration.
Open the Python workspaceCheck your understanding
You are stopped at a breakpoint() inside a helper and want to see the value of a variable that belongs to the function that called it, without letting the program advance. What do you do?
- Type u to move up one stack frame, then p on the variable name
- Type r to return from the helper, then p on the variable name
- Type n until the helper finishes, then p on the variable name
- Type c and add a second breakpoint() in the caller
Show answer
u only shifts which existing frame pdb evaluates in; no code runs, so the state you are debugging is untouched. r looks similar but actually executes the rest of the helper, which can mutate the very values you were trying to observe.