Print Debugging
In Lesson 1, James learned to read tracebacks: the red text that tells you what crashed, where, and why. He fixed the reading_time_seconds bug because Python's TypeError pointed straight at the problem line.
But now he is staring at a different function. reading_time_minutes runs without errors. No red text. No traceback. The test just says the result is wrong:
FAILED test_smartnotes_buggy.py::TestReadingTimeMinutes::test_short_note_has_fractional_time
assert 0 == pytest.approx(0.04, abs=0.01)
"The function returns 0 when it should return 0.04," James says. "But Python didn't crash. There's nothing to read bottom-up."
"Right," Emma says. "This is a logic error. The code runs fine; it just does the wrong math. Python can't tell you about it because, from Python's perspective, nothing went wrong."
"So how do I find it?"
"You add temporary checkpoints. Print statements that show you what's happening inside the function while it runs."
A logic error is a bug where the code runs without crashing but produces the wrong result. It is the opposite of the crashes you saw in Lesson 1. Logic errors are harder to find because Python gives you no error message. You have to look inside the function yourself.
Python's print() is equivalent to console.log() in JavaScript or System.out.println() in Java. The same strategy applies: insert output statements to inspect runtime values. Python also has a built-in pdb debugger, but print debugging is faster for small functions and is the focus of this lesson.
The Bug
Open smartnotes_buggy.py and find the reading_time_minutes function:
def reading_time_minutes(note: Note, words_per_minute: int = 250) -> float:
"""Return estimated reading time in minutes as a decimal."""
return note.word_count // words_per_minute
Run the test to confirm the failure:
uv run pytest test_smartnotes_buggy.py::TestReadingTimeMinutes::test_short_note_has_fractional_time -v
Output:
FAILED - assert 0 == pytest.approx(0.04, abs=0.01)
The function returns 0. The test expects 0.04. The function is only one line of actual logic, but pretend for a moment that you cannot see the bug. In real code, the function might be 30 lines long and the problem could be anywhere.
Step 1: Print the Inputs
Start by verifying what goes into the function. Add a print statement at the top:
def reading_time_minutes(note: Note, words_per_minute: int = 250) -> float:
"""Return estimated reading time in minutes as a decimal."""
print(f"DEBUG inputs: word_count={note.word_count}, wpm={words_per_minute}")
return note.word_count // words_per_minute
Run the test again:
uv run pytest test_smartnotes_buggy.py::TestReadingTimeMinutes::test_short_note_has_fractional_time -v -s
The -s flag tells pytest to show print output instead of capturing it.
Output:
DEBUG inputs: word_count=10, wpm=250
FAILED - assert 0 == pytest.approx(0.04, abs=0.01)
The inputs look correct: 10 words at 250 words per minute. The problem is not in the inputs.
Step 2: Print the Output
Now print the result right before returning it:
def reading_time_minutes(note: Note, words_per_minute: int = 250) -> float:
"""Return estimated reading time in minutes as a decimal."""
print(f"DEBUG inputs: word_count={note.word_count}, wpm={words_per_minute}")
result = note.word_count // words_per_minute
print(f"DEBUG result: {result}, type: {type(result)}")
return result
Output:
DEBUG inputs: word_count=10, wpm=250
DEBUG result: 0, type: <class 'int'>
Two clues. First, the result is 0 (confirmed). Second, the type is int, not float. The function signature says it returns float, but the actual value is an integer zero.
Step 3: Inspect the Operation
The only operation is note.word_count // words_per_minute. Print both the operation and what you expect it to produce:
def reading_time_minutes(note: Note, words_per_minute: int = 250) -> float:
"""Return estimated reading time in minutes as a decimal."""
print(f"DEBUG: {note.word_count} // {words_per_minute} = {note.word_count // words_per_minute}")
print(f"DEBUG: {note.word_count} / {words_per_minute} = {note.word_count / words_per_minute}")
result = note.word_count // words_per_minute
return result
Output:
DEBUG: 10 // 250 = 0
DEBUG: 10 / 250 = 0.04
There it is. // is floor division: it drops everything after the decimal point. 10 // 250 rounds down to 0. Regular / gives 0.04, which is the correct answer.
The Fix
Change // to /:
def reading_time_minutes(note: Note, words_per_minute: int = 250) -> float:
"""Return estimated reading time in minutes as a decimal."""
return note.word_count / words_per_minute
Run the test:
uv run pytest test_smartnotes_buggy.py::TestReadingTimeMinutes -v
Output:
PASSED test_short_note_has_fractional_time
PASSED test_long_note
Both tests pass. Remove all the debug print statements now that the bug is fixed.
The Binary Search Strategy
The reading_time_minutes function was short enough that three prints found the bug. Real functions can be 20 or 50 lines. Printing every variable on every line is wasteful.
Instead, use the binary search strategy: start in the middle.
James thinks about this. "In my warehouse, when a batch of products has a defect, we don't inspect every item one by one. We split the batch in half, check the midpoint, and figure out which half has the problem. Then we split that half again."
"Same idea," Emma says. "If you have a 20-line function and the output is wrong, put a print at line 10. If the value is already wrong at line 10, the bug is in lines 1 through 10. If the value is still correct at line 10, the bug is in lines 11 through 20. You just eliminated half the function."
The pattern:
- Add a print statement at the midpoint of the suspect area
- Run the code
- Is the value correct or wrong at that point?
- If wrong: the bug is above. Move the print up to the new midpoint.
- If correct: the bug is below. Move the print down to the new midpoint.
- Repeat until you have narrowed it to one or two lines.
For a 20-line function, this takes about 4 or 5 prints instead of 20.
When to Print vs When to Read the Traceback
Not every bug needs print debugging. Here is when to use each tool:
| Situation | Tool | Why |
|---|---|---|
| Code crashes with red text | Read the traceback (Lesson 1) | Python already told you what and where |
| Code runs but gives wrong result | Print debugging (this lesson) | No traceback exists; you need to look inside |
| You fixed a bug but want to understand why | Either | Traceback for crash context, prints for value inspection |
| The traceback points to a library, not your code | Print debugging | Print your values before the library call to find what you passed wrong |
Cleaning Up Debug Prints
After fixing a bug, remove every debug print statement. Leftover prints clutter the output and confuse anyone reading the code later.
One useful pattern for code you debug often: use a DEBUG flag.
DEBUG: bool = False
def reading_time_minutes(note: Note, words_per_minute: int = 250) -> float:
"""Return estimated reading time in minutes as a decimal."""
if DEBUG:
print(f"DEBUG: {note.word_count} / {words_per_minute}")
return note.word_count / words_per_minute
Set DEBUG = True when investigating, DEBUG = False when done. This saves you from retyping print statements if the same function breaks again. For short scripts and homework, deleting the prints is simpler.
PRIMM-AI+ Practice: Diagnosing a Silent Bug
Predict [AI-FREE]
Press Shift+Tab to enter Plan Mode.
Look at this function without running it. What will categorize_priority return when called with days_old=5? Write your prediction and a confidence score from 1 to 5. Then write down which print statement you would add first to verify your prediction.
def categorize_priority(days_old: int) -> str:
"""Return priority level based on note age in days."""
if days_old > 30:
priority: str = "archive"
if days_old > 7:
priority = "review"
else:
priority = "urgent"
return priority
Check your prediction
The function returns "urgent". The second if (not elif) is a separate check: 5 > 7 is False, so the else block runs and sets priority = "urgent".
But the intended behavior is probably: archive > 30, review > 7, urgent otherwise. The bug is using if instead of elif on line 5. With if, Python checks both conditions independently instead of treating them as a chain.
If you predicted "urgent" and identified the if vs elif issue, your trace was correct. A good first print would be right before the return: print(f"DEBUG priority={priority}").
Run
Press Shift+Tab to exit Plan Mode.
Create a file called priority_debug.py with the function above. Add a call at the bottom: print(categorize_priority(5)). Run uv run python priority_debug.py and compare the output to your prediction.
Investigate
Add print statements to trace the flow:
def categorize_priority(days_old: int) -> str:
"""Return priority level based on note age in days."""
if days_old > 30:
priority: str = "archive"
print(f"DEBUG: hit >30 branch, priority={priority}")
if days_old > 7:
priority = "review"
print(f"DEBUG: hit >7 branch, priority={priority}")
else:
priority = "urgent"
print(f"DEBUG: hit else branch, priority={priority}")
return priority
Test with days_old=15. What do the prints reveal? Run /investigate @priority_debug.py in Claude Code and ask why using if instead of elif changes the behavior. Then use /bug to classify this as a logic error before fixing.
Modify
Fix the bug by changing the second if to elif. Remove all print statements. Test with three values: days_old=45, days_old=15, and days_old=3. Verify each returns the expected priority.
Make [Mastery Gate]
Open smartnotes_buggy.py and find Bug #3 (the filter_notes_by_all_tags function). The test says it should return notes matching ALL given tags, but it returns notes matching ANY tag. Use only print debugging to find the exact line causing the wrong behavior. Do not ask Claude Code for the answer. Write your diagnosis: what operator is wrong and what should it be?
Hint
Add a print inside the list comprehension's condition. What does any() return versus what all() would return for the same input?
Try With AI
If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.
Prompt 1: Generate a Silent Bug
Write a Python function called calculate_discount that takes
a price (float) and a discount_percent (int) and returns the
discounted price. Introduce a subtle logic error that makes
the function return the wrong value without crashing. Do NOT
tell me what the bug is. Include type annotations and a
docstring.
Use print debugging to find the bug. Add prints for the inputs, the intermediate calculation, and the final result. Which print reveals the problem?
What you're learning: You are practicing the full print debugging loop on a bug you have not seen before, building the habit of inspecting values at each step rather than guessing.
Prompt 2: Print vs Traceback
When should I use print debugging versus reading the traceback
to find a Python bug? Give me a decision rule I can follow.
Compare the AI's answer to the table in this lesson. Does it match? Did it mention anything you had not considered?
What you're learning: You are building a mental decision tree for choosing the right debugging tool, and using the AI's response to check your own understanding.
Prompt 3: Clean Up
Review this code for leftover debug print statements. Remove
them and show me the clean version:
def reading_time_minutes(note, words_per_minute=250):
print(f"DEBUG inputs: {note.word_count}, {words_per_minute}")
result = note.word_count / words_per_minute
print(f"DEBUG result: {result}")
return result
Check the AI's cleaned-up version. Did it remove both prints? Did it change anything else you did not ask for? If it added type annotations or a docstring, that is a bonus, but make sure it did not alter the logic.
What you're learning: You are practicing code review of AI output, verifying the AI only removed what you asked it to remove and did not introduce new changes.
James nods slowly. "So the process is: print the inputs, print the output, and if those don't reveal the problem, print the intermediate steps. It's like adding quality checkpoints along a production line. You check the raw materials coming in, check the finished product going out, and if something is off, you inspect each station in between until you find where the defect starts."
"That's a good way to think about it," Emma says. "And the binary search trick means you don't need to check every station. Start in the middle, figure out which half has the problem, then split that half again."
James pauses. "One thing I'm not sure about. Is print debugging always the best approach, or should I use a real debugger for everything?"
Emma tilts her head. "Honestly, I am not sure there's a clear winner for small functions. Some experienced developers swear by the debugger for everything. Others print-debug functions under 20 lines and only pull out the debugger for complex call chains. The research is mixed. For now, print debugging gives you the fastest feedback loop for the bugs you're encountering."
"Fair enough. So I've handled two kinds of bugs now: ones that crash with a traceback and ones that run silently with the wrong answer. Are there other patterns?"
"Plenty. AI-generated code has specific recurring mistakes -- things like using any() when the spec says all(), or splitting strings on the wrong delimiter. Next lesson catalogs those patterns so you can recognize them on sight instead of print-debugging every time."