Tests reduce bugs; they don't extinguish them. One day the suite is green and Julia still pays a weird price, or close_till returns an absurd total: the bug lives in a case no test imagined. Debugging is not staring at the code waiting for a revelation: it's the scientific method applied to software — the bug is a hypothesis you formulate, put to the test and verify. In this lesson you'll build that method with the tools you already have (M7 tracebacks, M4 f-strings, the papyrus.log from M7) and apply it in full to a real Papyrus bug: a discount applied twice.
Contents
- The method: reproduce → isolate → fix → protect
- Rereading the traceback (now with the package's deep stack)
- Honest print-debugging: the f-string with
= loggingat DEBUG: prints with levels and memory- The minimal reproducible case
- Mental tools: rubber duck and
git bisect - Table of symptoms and usual suspects
- Case study: Papyrus's phantom discount
The method: reproduce → isolate → fix → protect
A bug is an empirical claim: "with this input, the program does something other than expected". You attack it in four phases, always in this order:
graph LR
R["1. REPRODUCE<br/>make it fail at will"] --> A["2. ISOLATE<br/>narrow down to the cause"]
A --> C["3. FIX<br/>the minimal change that repairs it"]
C --> P["4. PROTECT<br/>regression test: it must not return"]
- Reproduce. If you can't make it fail at will, you can't know whether you fixed it. "It fails sometimes" is not a starting point: it's the first hypothesis to refine (with what data? in what order? after what previous operation?).
- Isolate. Formulate hypotheses ("the stock gets corrupted in
restock") and design cheap experiments to rule them out: each discarded hypothesis tightens the ring. This is where prints, logs and the debugger (next lesson) live. - Fix. The minimal change that explains all of the symptom. If you fix without understanding why it fails, you haven't fixed: you've moved the bug.
- Protect. A regression test: the test that would have caught this bug, written now, red on the broken code and green after the fix. It's TDD (09-04) applied to the past: every fixed bug leaves the suite stronger, and that specific bug can't come back without setting off an alarm.
Rereading the traceback (now with the package's deep stack)
In 07-01 you learned to read tracebacks; back then they had two levels. With Papyrus made into a package, the stack is deep, and the technique scales just the same: read from the bottom up. At the bottom, the what (type and message); moving up, the where (the last line of yours is usually the hot lead):
Traceback (most recent call last):
File "workday.py", line 12, in <module>
total = charge_member(catalog, "Faust", 1)
File ".../papyrus/register.py", line 31, in charge_member
amount = sell(catalog, title, units)
File ".../papyrus/warehouse.py", line 58, in sell
price = book.final_price(member)
File ".../papyrus/models.py", line 27, in final_price
return round(base * (1 + BOOK_VAT), 2)
TypeError: unsupported operand type(s) for *: 'NoneType' and 'float'Reading from the bottom up: (1) a None reached a multiplication in final_price; (2) but final_price doesn't create that None — base comes from higher up; (3) the chain register → warehouse → models tells you the route the data traveled. The stack doesn't point to where the bug was born, but to where it exploded; the origin is usually one or several frames higher (here: someone assigned base = ... from a function that returned None — does row 1 of the suspects table below ring a bell?). That gap between explosion and origin is the reason for everything that follows.
Honest print-debugging: the f-string with =
Dropping in a print to see what's going on is not amateurish: it's the fastest tool for a specific hypothesis. Do it right, with the = specifier of f-strings you saw in 04-05 — it prints the expression and its value:
def sell(catalog, title, units):
book = get_book(catalog, title)
print(f"DEBUG: {title=} {units=} {book.stock=}") # ← temporary tattletale
...Output: DEBUG: title='Faust' units=1 book.stock=10 — with the repr's quotes, which distinguish '4' from 4 (how many bugs are a string where you expected an int!). Its limits, without romanticism:
| Limit of print | Consequence |
|---|---|
| You have to edit the code to see anything | And remember to remove it afterwards (forgotten prints end up in production) |
| You only see what you asked about | If the hypothesis was wrong, re-edit and rerun |
| It mixes with normal output | In close_till with a thousand rows, the tattletale drowns |
| No record remains | Tomorrow you won't know what the value was yesterday |
logging at DEBUG: prints with levels and memory
The last two limits are solved by something you already have set up: module 7's logging, which writes to data/papyrus.log. A logger.debug(...) is a print with superpowers: it has a level (turned on and off without touching code), context (date, module) and memory (it stays in the file):
import logging
logger = logging.getLogger(__name__)
def sell(catalog, title, units):
book = get_book(catalog, title)
logger.debug("sell: title=%r units=%d stock=%d", title, units, book.stock)
...Day to day, the logger's level sits at INFO and these lines cost almost nothing. When you're hunting a bug, you lower the threshold to DEBUG in the configuration (one line, in register.py) and the papyrus.log file becomes the plane's black box: you can reconstruct the exact sequence that preceded the failure — even a failure that happened yesterday on Luis's computer. Practical rule: print for two-minute hypotheses you delete right away; permanent logger.debug at the business hot spots (sales, till closes, file loads).
The minimal reproducible case
When the failure shows up "when closing Tuesday's till with the big catalog", the enemy is size: too many variables. The technique is to reduce: cut the data and steps in half, check whether it still fails, repeat. The goal is a minimal script that fails every time:
# minimal.py — the bug, distilled
from papyrus.models import Book
book = Book("The Odyssey", 12.50, 4)
print(book.final_price(member=True)) # prints 11.73, expected 12.35Three lines that fail are worth more than a whole business day that fails: they fit in your head, run in milliseconds and — bonus — they're one step away from becoming the phase-4 regression test. If the failure disappears while reducing, you've learned something too: the cause was in what you just removed.
Mental tools: rubber duck and git bisect
The rubber duck. Explain the problem out loud, line by line, to someone who knows nothing — classically, a rubber duck on your monitor. It really works: verbalizing forces you to turn assumptions ("this definitely returns the book") into explicit statements, and the bug usually lives in a false assumption. Half the time, the sentence "and then this multiplies by... wait" arrives before you finish the explanation.
git bisect (just the concept). If Papyrus worked last week and doesn't today, the question changes from where the bug is to when it got in. Git can do a binary search between a known good commit and the current bad one: it keeps proposing intermediate commits, you answer "good/bad" (or let the test suite answer — another reason to have one), and in log₂(n) steps it points to the exact commit that broke things. You don't need to master it today; you need to know it exists for the day when the "when" is the cheapest lead.
Table of symptoms and usual suspects
Over the years, debuggers develop a nose. Here's a head start, with the suspects this course has already introduced you to:
| Symptom | Usual suspect | Where you saw it |
|---|---|---|
TypeError ... 'NoneType' |
A function without return (implicit None), or find_book used without checking the None in its -> Book | None signature |
M3, M8 |
| Data that "changes on its own" | Shared mutation: two names for the same dict/list, shallow copy, mutable default argument | M4 |
0.30000000000000004, cents that wobble |
Binary float arithmetic; rounding in the wrong place | M1 |
Mangled accents (GarcÃa) or UnicodeDecodeError |
File read/written without encoding="utf-8" |
M6 |
KeyError with a key that "is there" |
Invisible spaces or case: "Hamlet " is not "Hamlet" (the repr via %r/{x=} gives them away) |
M4 |
| Works by hand, fails in the test (or vice versa) | State shared between tests, or real data vs. test data | 09-02/09-03 |
The table doesn't replace the method: it gives you the first hypothesis, which still has to be verified.
Case study: Papyrus's phantom discount
Let's apply the full method. Monday morning: Julia, a member, buys The Odyssey and the till charges her 11.73 EUR. Ana frowns: the canonical member price is 12.35 EUR.
Phase 1 — Reproduce. Ana writes the minimal case (the minimal.py above): Book("The Odyssey", 12.50, 4).final_price(member=True) → 11.73, every time. Reproduced at will, and along the way it becomes clear the bug is in models.py, not in register.py or in sell: the ring is already small.
Phase 2 — Isolate. Hypotheses in order of cost:
- H1: someone changed the constants. Experiment:
print(f"{MEMBER_DISCOUNT=} {BOOK_VAT=}")→MEMBER_DISCOUNT=0.05 BOOK_VAT=0.04. Ruled out. - H2: the rounding is wrong. Arithmetic by hand:
12.50 × 0.95 × 1.04 = 12.35; no rounding produces 11.73 from there. Ruled out. But the calculator drops a clue:11.73 ≈ 12.35 × 0.95. The amount carries two member discounts. - H3: the discount is applied twice. Ana opens
final_price— tweaked on Friday for the coupon campaign — and adds a tattletale:
def final_price(self, member: bool = False) -> float:
base = self.price * (1 - MEMBER_DISCOUNT) if member else self.price
print(f"{base=}") # tattletale: base=11.875 ✔ correct
if member:
base = base * (1 - MEMBER_DISCOUNT) # ← again! (Friday's line)
return round(base * (1 + BOOK_VAT), 2)On Friday, Ana added the if member: without seeing that the first line already discounted inside its conditional expression. 11.875 × 0.95 × 1.04 = 11.7325 → 11.73. Hypothesis confirmed: the symptom is 100% explained, cent by cent — the standard you must demand of yourself before touching anything.
Phase 3 — Fix. Minimal change: delete the duplicated if member: block (and the tattletale). The minimal case prints 12.35.
Phase 4 — Protect. Why didn't the suite go off? Ana runs it now: the parametrized test from 09-03 with the four member prices fails red on the broken code... but she hadn't run it on Friday before leaving. Two protections, then: the already-existing parametrized test (which would have been enough — moral: the suite only protects if it's run always, after every change) and an explicit regression test documenting the incident:
def test_regression_member_discount_not_applied_twice():
"""Bug 2026-07: final_price applied the 5% discount twice (11.73 instead of 12.35)."""
book = Book("The Odyssey", 12.50, 4)
assert book.final_price(member=True) == pytest.approx(12.35)
assert book.final_price(member=True) != pytest.approx(11.73)Reproduce, isolate with cheap hypotheses, fix the minimum, protect with a test. Four phases, zero guessing.
Common Mistakes and Tips
- Changing code before reproducing. "Try changing this and see if..." is programming by superstition: if the symptom disappears, you don't know whether you fixed it or hid it.
- Changing two things at once. If you touch two lines and the bug dies, you don't know which one killed it — and maybe one of the two broke something else. One experiment, one variable.
- Trusting memory instead of writing things down. With three hypotheses in the air, note which ones you ruled out and with what experiment; rereading it is the rubber duck on paper.
- Leaving debug
prints in the code. They dirty the output and confuse the next person. Temporary tattletales get deleted along with the fix; whatever deserves to stay should stay aslogger.debug. - Blaming Python or the library first. 99.9% of the time the bug is yours. Exhaust your own hypotheses before suspecting the interpreter.
- Tip: when the fix works, explain the cent. If you can't justify arithmetically why it produced 11.73 before and 12.35 now, you haven't finished understanding the bug.
Exercises
Exercise 1
close_till returns 41.40 for a sales.csv whose rows clearly add up to 62.10 (three sales: 20.70 + 20.70 + 20.70). Write the first three hypotheses you'd investigate, ordered from cheapest to most expensive to check, and the concrete experiment (one line of code or shell) for each. Hint: 41.40 = 62.10 − 20.70.
Exercise 2
This traceback appears when running a Papyrus workday. Read it from the bottom up and answer: (a) what exploded and where?, (b) where would you look for the origin?, (c) which row of the suspects table applies?
Traceback (most recent call last):
File "workday.py", line 8, in <module>
print(book.final_price(member=True))
File ".../papyrus/models.py", line 27, in final_price
base = self.price * (1 - MEMBER_DISCOUNT)
TypeError: can't multiply sequence by non-int of type 'float'Exercise 3
Turn the minimal case from exercise 1 (the lost row) into a regression test with tmp_path (09-03), assuming the cause was: the last row of the CSV is ignored if the file doesn't end with a newline.
Solutions
Exercise 1 — Exactly one row is missing (62.10 − 41.40 = 20.70): the mother hypothesis is "one row isn't being summed".
- H1 (seconds): a row is being discarded as corrupt. Experiment: lower the logger to DEBUG and rerun — the "row skipped" warning from M7 should show up in
papyrus.log. - H2 (a minute): the last row isn't read (file without a trailing
\n?). Experiment: minimal case with 3 rows intmp_path, with and without a final newline. - H3 (minutes): the
only_from_dayfilter (08-03) discards a sale by date. Experiment:print(f"{row=}")inside the pipeline, or the three rows with the same date in the minimal case.
Exercise 2 — (a) A multiplication exploded in final_price (models.py, line 27): self.price is a sequence (a string like "12.50"), not a float — multiplying a string by an int replicates it ("ab" * 3), but by a float it blows up. (b) The origin isn't in models.py: it's wherever that Book was built with the unconverted price — almost certainly a CSV load that forgot float(row["price"]) (M6: everything that comes out of a CSV is text). (c) The KeyError/repr row is a cousin, but the one that applies is the first of the "type" ones: a string where you expected a number — the {x=} with its repr (self.price='12.50', with quotes) would have given it away instantly. Note: here __post_init__ validated the price as a positive number, yet a string slipped through — a good candidate for hardening the validation with a test.
Exercise 3
def test_regression_last_row_without_trailing_newline_is_summed(tmp_path):
"""Bug: the last row was lost if the CSV didn't end with \\n."""
path = tmp_path / "sales.csv"
path.write_text(
"date,title,amount\n"
"2026-07-10,Hamlet,20.70\n"
"2026-07-10,Hamlet,20.70\n"
"2026-07-10,Hamlet,20.70", # ← no trailing newline, on purpose
encoding="utf-8",
)
assert close_till(path) == pytest.approx(62.10)The comment on the degenerate case (on purpose) is part of the test: a year from now, nobody should "fix" that detail without understanding it's the essence of the regression.
Conclusion
You no longer debug by intuition: you reproduce at will, isolate with cheap hypotheses (traceback read bottom-up, {x=} with its telltale repr, logger.debug into papyrus.log as the black box, a minimal case that fits in three lines), fix the minimum while explaining every cent, and protect with a regression test that welds the bug shut forever — like Julia's phantom discount, hunted down with the full method. But you'll have noticed the back-and-forth: add a tattletale, run, read it, remove it, add another... When the hypothesis demands seeing the program from the inside and in motion — stopping execution on a line, inspecting any variable, stepping forward one line at a time — prints fall short. That's what the interactive debugger is for: pdb in the terminal and its visual sibling in VS Code. It's the last piece of your safety net, and the next lesson.
Python Programming Course
Module 1: Introduction to Python
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
