You mentioned forty-seven print statements. I want to look at one before we do anything else.
The one that says # debug prints — remove before PR? That comment is six months old.
Six months. It has become load-bearing. Let me ask you a direct question: what happens when the bug only appears in production, with data you cannot reproduce locally, and adding another print statement means redeploying?
I add logging and redeploy and wait for the bug to recur. It can take a day. Sometimes longer.
That is the ceiling of print-debugging. It works until the thing you need to see is not the thing you thought to print. breakpoint() changes the question entirely. You do not decide in advance what to observe — you arrive at the running program and look at everything.
I tried pdb once, got confused by the command interface, went back to print statements. What is the actual entry point?
One line, since Python 3.7:
def process_order(order_id, customer_id, items):
customer = fetch_customer(customer_id)
inventory = check_inventory(items)
breakpoint() # execution pauses here — debugger opens
total = calculate_total(items, customer.get("discount"))
return charge_customer(customer["payment_method"], total)When Python hits breakpoint(), the program pauses. Every variable in scope is live. You are not reading a witness account of what happened — you are at the crime scene, and you can examine anything.
What can I do once it opens?
Four commands cover ninety percent of sessions:
(Pdb) n → next — execute the current line, step over calls
(Pdb) s → step — step INTO the next function call
(Pdb) c → continue — run until the next breakpoint
(Pdb) p expr → print — evaluate any Python expression in current scopeWhen total comes back as 0.0, instead of adding another print and rerunning: p customer.get("discount") returns 1.0. A 100% discount. The bug is not in the calculation. The data is wrong upstream. You found it in thirty seconds without touching a file.
I can call any function while the program is paused. p calculate_total(items, 0.1) would show me what the correct result should be — while the original run is still live.
The p command evaluates any Python expression in the current frame. Call functions, index into structures, run comprehensions. Print-debugging locks in the questions before you run. pdb lets you ask questions after you see the evidence.
Conditional breakpoints. I have a loop over ten thousand orders where the bug appears on one specific order. A plain breakpoint() inside the loop fires ten thousand times.
Two approaches. From inside the session, b process_order, order_id == 8743 sets a conditional breakpoint that fires only when the condition is true. Or inline in code:
for order in orders:
result = process_order(order["id"], order["customer_id"], order["items"])
if result["status"] == "failed" and order["id"] == 8743:
breakpoint() # fires exactly onceThe conditional breakpoint is the detective saying: only call me if the suspect is at the scene.
What about post-mortem? When the script crashes and I want to inspect the state at the crash site without setting a breakpoint in advance?
pdb.pm() — post-mortem. Run the script, it crashes, then:
import pdb, traceback
try:
main()
except Exception:
traceback.print_exc()
pdb.pm() # opens debugger at the crash frameEvery local variable at the crash site is live. up moves you one frame toward the caller. w prints the full call stack. You are not reading a static traceback — you are navigating the timeline of the crash.
Last month I spent an afternoon on a crash that only appeared with a specific CSV file. I added seven print statements and re-ran three times. Post-mortem would have shown me the variable in the exact state that triggered it on the first try.
That is the exact scenario it eliminates. The state is preserved. p locals() shows every variable in the frame. p type(value) shows what type arrived when a different type was expected. The crash is a crime scene and pdb.pm() lets you look around.
up moves up the call stack. So if the crash is three calls deep I can walk up and inspect variables at each level.
Exactly. And breakpoint() respects PYTHONBREAKPOINT. Set it to 0 in CI and every breakpoint silently becomes a no-op — no code changes needed:
PYTHONBREAKPOINT=0 python my_script.py # all breakpoints silenced
PYTHONBREAKPOINT=ipdb.set_trace python my_script.py # swap debuggerThe six-month # debug prints — remove before PR comment is the exact problem this environment variable was designed to solve. You can leave breakpoints in the code during development. CI never sees them.
My entire print-debugging workflow — the comments, the re-runs, the deferred removals — it exists because I did not know about PYTHONBREAKPOINT.
And now you do. For today's challenge, you will build a function that models what pdb shows when it pauses: the context window of source lines around the breakpoint, the types of every variable in scope, a preview of the next executable line, and a simulated call stack built from def statements found before the breakpoint. You are reconstructing the information a detective has at the crime scene. Tomorrow: stack traces — how to read them methodically, and why the bottom is where the crime happened but the top is where it started.
I always read tracebacks from the bottom. I have a feeling you are about to tell me the top matters more than I think.
The bottom is where the exception raised. The top is the entry point — where the execution started. The real bug is usually somewhere in between, and you cannot find it if you only read one end.
The tracing mechanism. pdb is built on sys.settrace(), a low-level CPython hook that installs a callback function invoked on every line execution, function call, and exception event. When breakpoint() is called, it invokes sys.breakpointhook(), which by default calls pdb.set_trace(). pdb.set_trace() calls sys.settrace(self.trace_dispatch), installing the pdb instance as the trace callback. From that point, CPython calls trace_dispatch on every bytecode event, and pdb checks whether any registered breakpoint conditions are satisfied. This is why adding breakpoint() has a measurable performance overhead even when execution never pauses — the trace hook runs on every line.
Frame objects and live state. When pdb pauses execution, the current frame is a frame object accessible via sys._getframe() or the f attribute of the pdb instance. The frame holds f_locals (local variable namespace), f_globals (module globals), f_code (the code object with line number mappings), and f_back (the calling frame). The p expr command evaluates the expression string using eval(expr, f_globals, f_locals) — the exact same mechanism as Python's built-in eval. This is why you can call any function from the pdb prompt: you are evaluating Python code in the live namespace of the paused frame.
Call stack navigation. up and down move pdb.curframe to adjacent frames in the f_back chain. Each frame retains its local variables even after the function has been called and control passed to a callee — the frame is suspended, not destroyed. Post-mortem debugging via pdb.pm() works because CPython saves the traceback object of the most recent unhandled exception in sys.last_traceback. pdb.pm() extracts the innermost frame from that traceback and sets it as curframe, giving you access to the state at the exact moment of the crash.
PYTHONBREAKPOINT and the hook. breakpoint() does not call pdb directly — it calls sys.breakpointhook(). CPython reads PYTHONBREAKPOINT at startup and configures sys.breakpointhook accordingly. PYTHONBREAKPOINT=0 installs a no-op hook. Any other value is interpreted as a dotted import path: PYTHONBREAKPOINT=ipdb.set_trace imports ipdb and calls set_trace. This design means all debugging infrastructure — the specific debugger, whether breakpoints fire at all — is controlled entirely at the process boundary without touching source code.
Sign up to write and run code in this lesson.
You mentioned forty-seven print statements. I want to look at one before we do anything else.
The one that says # debug prints — remove before PR? That comment is six months old.
Six months. It has become load-bearing. Let me ask you a direct question: what happens when the bug only appears in production, with data you cannot reproduce locally, and adding another print statement means redeploying?
I add logging and redeploy and wait for the bug to recur. It can take a day. Sometimes longer.
That is the ceiling of print-debugging. It works until the thing you need to see is not the thing you thought to print. breakpoint() changes the question entirely. You do not decide in advance what to observe — you arrive at the running program and look at everything.
I tried pdb once, got confused by the command interface, went back to print statements. What is the actual entry point?
One line, since Python 3.7:
def process_order(order_id, customer_id, items):
customer = fetch_customer(customer_id)
inventory = check_inventory(items)
breakpoint() # execution pauses here — debugger opens
total = calculate_total(items, customer.get("discount"))
return charge_customer(customer["payment_method"], total)When Python hits breakpoint(), the program pauses. Every variable in scope is live. You are not reading a witness account of what happened — you are at the crime scene, and you can examine anything.
What can I do once it opens?
Four commands cover ninety percent of sessions:
(Pdb) n → next — execute the current line, step over calls
(Pdb) s → step — step INTO the next function call
(Pdb) c → continue — run until the next breakpoint
(Pdb) p expr → print — evaluate any Python expression in current scopeWhen total comes back as 0.0, instead of adding another print and rerunning: p customer.get("discount") returns 1.0. A 100% discount. The bug is not in the calculation. The data is wrong upstream. You found it in thirty seconds without touching a file.
I can call any function while the program is paused. p calculate_total(items, 0.1) would show me what the correct result should be — while the original run is still live.
The p command evaluates any Python expression in the current frame. Call functions, index into structures, run comprehensions. Print-debugging locks in the questions before you run. pdb lets you ask questions after you see the evidence.
Conditional breakpoints. I have a loop over ten thousand orders where the bug appears on one specific order. A plain breakpoint() inside the loop fires ten thousand times.
Two approaches. From inside the session, b process_order, order_id == 8743 sets a conditional breakpoint that fires only when the condition is true. Or inline in code:
for order in orders:
result = process_order(order["id"], order["customer_id"], order["items"])
if result["status"] == "failed" and order["id"] == 8743:
breakpoint() # fires exactly onceThe conditional breakpoint is the detective saying: only call me if the suspect is at the scene.
What about post-mortem? When the script crashes and I want to inspect the state at the crash site without setting a breakpoint in advance?
pdb.pm() — post-mortem. Run the script, it crashes, then:
import pdb, traceback
try:
main()
except Exception:
traceback.print_exc()
pdb.pm() # opens debugger at the crash frameEvery local variable at the crash site is live. up moves you one frame toward the caller. w prints the full call stack. You are not reading a static traceback — you are navigating the timeline of the crash.
Last month I spent an afternoon on a crash that only appeared with a specific CSV file. I added seven print statements and re-ran three times. Post-mortem would have shown me the variable in the exact state that triggered it on the first try.
That is the exact scenario it eliminates. The state is preserved. p locals() shows every variable in the frame. p type(value) shows what type arrived when a different type was expected. The crash is a crime scene and pdb.pm() lets you look around.
up moves up the call stack. So if the crash is three calls deep I can walk up and inspect variables at each level.
Exactly. And breakpoint() respects PYTHONBREAKPOINT. Set it to 0 in CI and every breakpoint silently becomes a no-op — no code changes needed:
PYTHONBREAKPOINT=0 python my_script.py # all breakpoints silenced
PYTHONBREAKPOINT=ipdb.set_trace python my_script.py # swap debuggerThe six-month # debug prints — remove before PR comment is the exact problem this environment variable was designed to solve. You can leave breakpoints in the code during development. CI never sees them.
My entire print-debugging workflow — the comments, the re-runs, the deferred removals — it exists because I did not know about PYTHONBREAKPOINT.
And now you do. For today's challenge, you will build a function that models what pdb shows when it pauses: the context window of source lines around the breakpoint, the types of every variable in scope, a preview of the next executable line, and a simulated call stack built from def statements found before the breakpoint. You are reconstructing the information a detective has at the crime scene. Tomorrow: stack traces — how to read them methodically, and why the bottom is where the crime happened but the top is where it started.
I always read tracebacks from the bottom. I have a feeling you are about to tell me the top matters more than I think.
The bottom is where the exception raised. The top is the entry point — where the execution started. The real bug is usually somewhere in between, and you cannot find it if you only read one end.
The tracing mechanism. pdb is built on sys.settrace(), a low-level CPython hook that installs a callback function invoked on every line execution, function call, and exception event. When breakpoint() is called, it invokes sys.breakpointhook(), which by default calls pdb.set_trace(). pdb.set_trace() calls sys.settrace(self.trace_dispatch), installing the pdb instance as the trace callback. From that point, CPython calls trace_dispatch on every bytecode event, and pdb checks whether any registered breakpoint conditions are satisfied. This is why adding breakpoint() has a measurable performance overhead even when execution never pauses — the trace hook runs on every line.
Frame objects and live state. When pdb pauses execution, the current frame is a frame object accessible via sys._getframe() or the f attribute of the pdb instance. The frame holds f_locals (local variable namespace), f_globals (module globals), f_code (the code object with line number mappings), and f_back (the calling frame). The p expr command evaluates the expression string using eval(expr, f_globals, f_locals) — the exact same mechanism as Python's built-in eval. This is why you can call any function from the pdb prompt: you are evaluating Python code in the live namespace of the paused frame.
Call stack navigation. up and down move pdb.curframe to adjacent frames in the f_back chain. Each frame retains its local variables even after the function has been called and control passed to a callee — the frame is suspended, not destroyed. Post-mortem debugging via pdb.pm() works because CPython saves the traceback object of the most recent unhandled exception in sys.last_traceback. pdb.pm() extracts the innermost frame from that traceback and sets it as curframe, giving you access to the state at the exact moment of the crash.
PYTHONBREAKPOINT and the hook. breakpoint() does not call pdb directly — it calls sys.breakpointhook(). CPython reads PYTHONBREAKPOINT at startup and configures sys.breakpointhook accordingly. PYTHONBREAKPOINT=0 installs a no-op hook. Any other value is interpreted as a dotted import path: PYTHONBREAKPOINT=ipdb.set_trace imports ipdb and calls set_trace. This design means all debugging infrastructure — the specific debugger, whether breakpoints fire at all — is controlled entirely at the process boundary without touching source code.