The prints and logs of the previous lesson answer questions you asked before running. But sometimes you need to converse with the program while it runs: stop it on an exact line, ask about any variable — even the ones you didn't think to watch —, advance one line, step inside a call, and decide the next move in light of what you've just seen. That's an interactive debugger. Python ships one out of the box, pdb, and in this lesson you'll use it to chase a stock bug inside sell(), learn how to open it right where a pytest test failed, and compare it with VS Code's visual debugger. With it, Papyrus's safety net is complete.

Contents

  1. breakpoint(): pausing the world
  2. The essential commands (with mnemonics)
  3. Case study: chasing the stock bug inside sell()
  4. Post-mortem: pytest --pdb opens where the test failed
  5. The VS Code debugger: the same thing, with windows
  6. Good practices (and how not to leave a bomb in production)
  7. Closing the module: the complete safety net

breakpoint(): pausing the world

Since Python 3.7, one built-in function is all it takes:

def sell(catalog, title, units):
    book = get_book(catalog, title)
    breakpoint()          # ← execution PAUSES here
    ...

When it reaches that line, the program stops and the terminal becomes an interactive console inside the living program, with access to every local variable at that instant:

> .../papyrus/warehouse.py(57)sell()
-> if not isinstance(units, int):
(Pdb) book.stock
4
(Pdb) units
4

The (Pdb) prompt accepts debugger commands and normal Python expressions (book.stock, units * book.price, whatever you like). breakpoint() is actually a configurable wrapper around import pdb; pdb.set_trace(), the classic form you'll see in older code; today breakpoint() is preferred for being shorter and — as you'll see at the end — switchable off from outside. The line shown with -> is the next one to execute: you're standing right before it, in time to look at everything.

The essential commands (with mnemonics)

With ten commands you cover 95% of sessions. The letter is the initial of the English word — that's all the mnemonics you need:

Command Word (mnemonic) What it does
l list Shows the code around the current line (ll = the whole function)
n next Runs the current line without entering its calls
s step Runs the current line stepping into the function it calls
c continue Resumes until the next breakpoint (or the end)
b 62 break Sets a breakpoint on line 62 (b alone: lists them all)
p expr print Evaluates and prints an expression
pp expr pretty-print Like p, but nicely formatted (large dicts and objects)
w where Shows the call stack and your position in it
u / d up / down Moves up/down one frame in the stack (you can inspect the caller!)
q quit Aborts execution and exits the debugger

The pair that confuses beginners most: n steps over, s steps inside. If the current line is price = book.final_price(member), with n you get price already computed; with s you land on the first line of final_price to watch it work from the inside. And w/u/d are the 09-05 traceback made navigable: the same stack, but you can stroll through it with each frame's variables alive.

Case study: chasing the stock bug inside sell()

After a refactor in warehouse.py, the suite sings:

FAILED tests/test_warehouse.py::test_selling_entire_stock_leaves_zero
E   papyrus.errors.InsufficientStockError: insufficient stock of 'The Odyssey': 4 left, 4 requested

The message is already suspicious: "insufficient" with 4 requested and 4 left? Selling exactly the whole stock is legal (the edge case from 09-01). We reproduce with a minimal case and plant a breakpoint() at the entrance of sell, after the validations:

# minimal.py
from papyrus.warehouse import sell
from papyrus.models import Book

catalog = {"The Odyssey": Book("The Odyssey", 12.50, 4)}
sell(catalog, "The Odyssey", 4)    # legal: exactly the whole stock

Full session (your commands after (Pdb); annotated on the right):

$ python minimal.py
> .../papyrus/warehouse.py(60)sell()
-> if book.stock <= units:
(Pdb) p book.stock, units           # what exactly is it comparing?
(4, 4)
(Pdb) p book.stock <= units         # we evaluate the condition BY HAND
True                                # ← it's going to raise the exception!
(Pdb) ll                            # let's see the whole function
 55     def sell(catalog, title, units):
 ...
 60  ->     if book.stock <= units:
 61             raise InsufficientStockError(title, units, book.stock)
(Pdb) n                             # confirm: next executes the if...
> .../papyrus/warehouse.py(61)sell()
-> raise InsufficientStockError(title, units, book.stock)
(Pdb) q                             # seen enough

Diagnosis in four commands: the condition says <= where the contract demands < — with stock 4 and 4 units requested there is no shortage; there's a shortage when they ask for more than is available. It's the classic off-by-one with comparison operators. Note the key move: p book.stock <= units evaluated the suspicious condition before executing it — something no retrospective print can do. You fix <=<, the suite goes back to green (the failing test was itself already the regression that protects this edge), and as a bonus you can check with p that after the sale book.stock == 0.

Post-mortem: pytest --pdb opens where the test failed

In the previous case we edited the code to plant the breakpoint(). There's a better shortcut when what fails is a test:

pytest --pdb                 # on the first failure, opens pdb AT the failure point
pytest -x --pdb              # combined with -x: stop and debug the first red

It's an autopsy (post-mortem): the test already failed, but pdb leaves you inside the corpse with every variable from the moment of failure intact — the test's catalog, the exception, everything. From there, w shows you the stack, u climbs from the test into sell, and p interrogates whatever is needed. Without editing a single line. For code that isn't a test, there's the equivalent python -m pdb minimal.py (runs under the debugger from the start and drops into post-mortem mode if it blows up). The professional flow with pytest: pytest detects, pytest --lf --pdb drops you at the scene of the crime.

The VS Code debugger: the same thing, with windows

Everything above exists in visual form. In VS Code (with the Python extension): you click the left margin of a line (a red dot appears: a visual breakpoint, pdb's b), launch with F5 (or "Debug Test" on a specific test), and when execution stops you have: a variables panel (all locals, without asking with p), watch (watched expressions re-evaluated on every step), the clickable call stack (the w/u/d), and buttons for step over (n), step into (s) and continue (c). The concepts are identical — which is why we learned pdb first: whoever understands n/s/c understands any debugger in any language.

Which to use? It's not religion, it's context:

Situation Best option
Exploring a complex bug on your machine, unhurried VS Code: seeing all locals at once speeds up hypothesis-forming
You're on a server over SSH, no graphical environment pdb: it's always there, needs nothing
A specific test fails and you want the exact context pytest --pdb (or "Debug Test" in VS Code)
Quick session: one suspicious condition, two questions breakpoint() + pdb: planted in 5 seconds
You want to evaluate arbitrary expressions on the fly Tie: the (Pdb) console or VS Code's Debug Console

Good practices (and how not to leave a bomb in production)

  • Never leave a breakpoint() in code you ship. It's the nuclear version of the forgotten print: someone else's program will stop dead waiting for a console that may not exist. Before declaring a debugging session closed, search and destroy: grep -rn "breakpoint()" papyrus/ (with Grep/Select-String in your environment).
  • The external safety net: the environment variable PYTHONBREAKPOINT=0 makes every breakpoint() be ignored without touching code — used as a seatbelt in production environments. Its existence doesn't excuse you from cleaning up; it saves you on the day you didn't.
  • Leave the debugger better than you found it: if during the session you discovered a value that "should be impossible", turn it into an assert or a test before closing. The pdb session is ephemeral; the test stays.
  • Don't debug what a log already answers. Opening pdb to learn "did this function even run?" is using a cannon on a fly: that's a logger.debug (09-05). The debugger is for questions that need the full state and the ability to step forward.
  • In pdb, beware of variables named like commands: p l prints the variable l... if it exists; typing plain l lists code. If your variable is called n or c, use p n — and better yet: don't call a variable n (M1 already asked you for meaningful names).

Closing the module: the complete safety net

Look at the net Papyrus has woven across modules 8 and 9 — four layers, each catching what slips past the previous one:

Layer Tool What it catches When it acts
Types mypy + type hints (08-01) Shape contracts: a str where an int goes Before running
Behavior Test suite: pytest (09-01→09-04) Value contracts: 12.35, not 12.36 On every change, in seconds
Live diagnosis pdb / VS Code (09-06) The why of an already-reproduced failure During the bug hunt
Memory logging to papyrus.log (M7, 09-05) What happened while you weren't looking Always, in production too

No layer replaces the others: mypy doesn't know the discount is 5%, tests don't explain why they fail, pdb doesn't protect the future and logs prevent nothing. Together, they turn "changing a line of Papyrus" — the question that opened this module — into a fearless act: if something breaks, some layer will shout, and you'll know where, when and why.

Common Mistakes and Tips

  • Mixing up n and s and "getting lost" inside a standard-library function after one s too many: don't panic — r (return) runs until the current function exits, and u reminds you where you called from.
  • Pressing Enter with no command: pdb repeats the last command. It's extremely handy for advancing with n, n, Enter, Enter... and baffling if you don't know it (why is it advancing when I typed nothing?).
  • Debugging the wrong version of the code: if you edit a file while pdb is open, the session keeps running the old code (already loaded). Exit, rerun and go back in.
  • Using q expecting the program to continue: q aborts the whole run (you'll see BdbQuit). For "let me go and carry on normally", the command is c.
  • Abusing the debugger as a substitute for tests: if every change requires a pdb session to convince yourself it works, you're missing tests. The debugger diagnoses; the suite guarantees.
  • Tip: in pytest --pdb, your first command should almost always be w (where am I?) and the second pp on the central data structure (here, pp catalog). Getting oriented before moving saves half the session.

Exercises

Exercise 1

Without running anything, write the exact sequence of pdb commands for this mission: you're stopped with breakpoint() on the first line of charge_member (in register.py), which further down calls sell. You want to: (a) see the whole function, (b) advance to the line with the call to sell (two lines down), (c) step inside sell, (d) once inside, view the stack to confirm where you came from, (e) pretty-print the full catalog, (f) resume normal execution to the end.

Exercise 2

A colleague hands you this diff as a "fix" after a debugging session. Point out the two problems that should never reach the repository and explain what each would do in production:

 def restock(catalog, title, units):
     book = get_book(catalog, title)
+    breakpoint()
     if not isinstance(units, int) or units <= 0:
         raise ValueError(f"Invalid units: {units}")
+    print(f"DEBUG {title=} {units=}")
     book.stock += units

Exercise 3

The test test_close_till_sums_only_valid_rows (09-03) fails after a refactor of the generator pipeline. Describe, step by step, how you'd use pytest to reach the failure point without editing any file, and the first three questions (expressions for p/pp) you'd ask once inside.

Solutions

Exercise 1

(Pdb) ll        # (a) the whole function, with the arrow on the current line
(Pdb) n         # (b) advance one line...
(Pdb) n         #     ...and another: the arrow now points at the call to sell
(Pdb) s         # (c) step: goes INSIDE sell
(Pdb) w         # (d) the stack: workday.py → charge_member → sell (you're at the bottom)
(Pdb) pp catalog    # (e) the catalog, nicely formatted
(Pdb) c         # (f) continue: releases the program to the end

(For (b), b 33 + c also works if you know the line number: plant a break and continue up to it — more comfortable when there are ten lines in between.)

Exercise 2 — Problem 1: the breakpoint(). In production, restock would stop dead waiting for input from a debugger nobody is attending: Papyrus frozen mid-restock (mitigable with PYTHONBREAKPOINT=0 in the environment, but the bomb must not ship). Problem 2: the debug print, which pollutes the standard output of any program using the package (imagine the till close printing hundreds of DEBUG lines). If that trace has permanent value, its correct form is logger.debug("restock: %r +%d", title, units) — silent by default, switchable on when needed (09-05).

Exercise 3 — Steps: (1) pytest --lf to confirm that only that test fails and see the message; (2) pytest --lf --pdb — on failure, pdb opens at the exact failure point with the state intact; (3) orientation: w to see the stack (did it fail in the test's assert or inside close_till? with u/d you move to the interesting frame). Three typical first questions: p path.read_text() (what exactly did the test's CSV contain?), pp on the pipeline's intermediate structure if you're in a close_till frame (for example, materialize p list(parse_rows(read_sales(path))) to see which rows survived parsing — careful: consuming a generator in pdb exhausts it for the rest of the run, legitimate in an autopsy), and p total or the assert's expression to see the value that was compared. With that you'll know whether the refactor loses rows, parses the amounts badly or filters too much.

Conclusion

Module 9 answered the question module 8 left us hanging on. Now, when Ana changes a line, she doesn't cross her fingers: she runs pytest and in seconds knows whether the four member prices are still 12.35, 9.83, 15.71 and 20.75 (09-01, 09-02, 09-03). The contracts of sell — the amount with VAT, the stock intact after an InsufficientStockError — stopped being promises and are tests that fail red if anyone breaks them. The coupons were born the other way around, and better for it: test first, minimal code after, fearless refactor (09-04). And when something breaks anyway, there's a method — reproduce, isolate, fix, protect with a regression (09-05) — and there's a magnifying glass: breakpoint(), four pdb commands and the suspicious condition evaluated live before it runs (09-06). With mypy watching the types, the suite watching the behavior, the debugger for the why and papyrus.log as memory, Papyrus finally has a complete safety net. But look up: all of this — catalog, sales, coupons, till closes — lives in a single place, Ana's computer, and only she can use it. Luis would like to check the stock from home; Julia, to reserve a book from her phone. The next frontier isn't making Papyrus work better, but opening it to the world: turning it into a web application. That's module 10.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved