Debugging with print, breakpoints and the VS Code debugger
The errors so far have announced themselves. The harder bugs do not: the program runs, finishes, and produces a wrong answer. No traceback, nothing to read — you have to find it.
This lesson is about doing that systematically rather than by staring.
Start with the scientific method
The instinct is to change something and re-run. Resist it. Changing code at random occasionally works, teaches you nothing, and sometimes hides the bug somewhere worse.
Instead:
- Reproduce it reliably. A bug you cannot trigger on demand cannot be confirmed fixed.
- Find the smallest input that shows it. A thousand-row file that fails is less useful than the one row that does.
- Form a hypothesis. "The total is wrong because the discount is applied after tax."
- Test that one hypothesis. Print the value, or step to that line.
- Repeat. Each test rules something out, and ruling things out is progress even when it does not feel like it.
The slow part is almost always step 3. If you are not sure what you believe is happening, you cannot test it — and that is the moment to stop typing and read the code.
Print debugging, done properly
There is nothing wrong with print. It is the fastest tool for most bugs, and
professionals use it constantly.
There is something wrong with this:
print(x)
print("here")
print(total)
Ten of those and you cannot tell which is which. Label them:
print(f"{subtotal = }")
print(f"{discount = }")
print(f"{total = }")
subtotal = 1000
discount = 200
total = 944.0
That = inside the braces is the f-string trick from module 2, and it earns its
place here — the label can never drift from the value it prints.
Three habits that make print debugging much better:
Print types when the value looks right but behaves wrong.
print(f"{value = } {type(value) = }")
A "5" and a 5 print identically. repr shows the difference:
print(f"{value!r}") # '5' or 5
Use !r whenever whitespace might be involved. "priya " and "priya" are
indistinguishable otherwise, and that trailing space is why your comparison
fails.
Print inside loops, with the iteration.
for i, row in enumerate(rows):
print(f"--- row {i}: {row!r}")
The failure is usually on one specific row, and that tells you which.
Print before and after the suspect line, not just after. Knowing a value was already wrong on the way in moves the search upstream immediately.
Then remove them when you are done. Committed debug prints are noise, and eventually they print customer data into a log.
The debugger
print requires you to guess what to look at. A debugger lets you stop and look
at everything.
The zero-setup version works anywhere:
def calculate_total(subtotal, discount):
breakpoint()
taxable = subtotal - discount
return taxable * 1.18
Run the program normally and it pauses there, giving you a prompt:
> app.py(3)calculate_total()
-> taxable = subtotal - discount
(Pdb)
The commands worth knowing — there are more, and these five cover most of it:
| Command | Does |
|---|---|
n |
run the next line, stepping over function calls |
s |
step into the function on this line |
c |
continue until the next breakpoint or the end |
p name |
print a variable |
q |
quit |
You can also type any Python expression at the prompt. subtotal - discount
evaluates right there, against the real values, which is the thing print
debugging cannot do.
In VS Code
Better for anything more than a quick look.
- Click in the gutter to the left of a line number. A red dot appears — that is a breakpoint.
- Press
F5, or Run → Start Debugging. - The program runs and stops at the dot.
The left panel shows every variable in scope without printing anything, and the
call stack shows how you arrived. Step with the toolbar or F10 (over)
and F11 (into).
The genuinely valuable feature is the conditional breakpoint: right-click a
breakpoint, add a condition like row_number == 847, and it only stops there.
Finding the one bad row in ten thousand goes from impossible to instant.
Bisecting
When you have no hypothesis at all, cut the problem in half.
Put a print halfway through. Is the value already wrong? The bug is in the first half. Still right? It is in the second. Repeat. Ten steps narrows a thousand lines to one.
The same idea works across time. If it worked last week and not today,
git bisect finds the commit that broke it by checking out midpoints and asking
you to test. On a repository with a decent history it is remarkably effective.
Rubber ducking
Explain the code, out loud, line by line, to something that does not care — traditionally a rubber duck.
It sounds silly and works often enough that it is a standard technique. Saying
"and then this returns the sorted list" out loud is what makes you notice that
it returns None. Reading silently lets your eyes skip the assumption; speaking
does not.
Writing the explanation works as well, which is why the act of composing a good question sometimes answers it.
Bugs that are not where you think
A few patterns worth recognising, since each has cost this course a lesson:
A value is None unexpectedly. Something returned None — a function
missing a return, or an in-place method like .sort() whose result you
assigned.
A list changed when you did not touch it. Two names on one list. Module 4.
A default argument accumulates between calls. The mutable default. Module 5.
A variable does not change inside a function. Scope, or reassigning an immutable value. Module 5.
A number is slightly wrong. Floating point. Module 2.
A comparison fails on identical-looking strings. Whitespace or case. !r
shows it.
A loop skips items. You modified the collection while iterating it.
Most bugs you will hit this year are on that list.
When you are stuck
Take a break. Genuinely. The fix arriving in the shower is a cliché because it happens constantly.
Explain it to somebody, or write it out properly.
Check your assumptions instead of your logic. The bug is rarely in the line you keep re-reading. It is in the thing you are certain about and have not verified — that the file has a header row, that the API returns a list, that the ID is a string.
Make it smaller. Delete everything not needed to reproduce it. Usually the bug becomes obvious before you finish.
Practice
- Write a function with a deliberate logic bug — a discount applied after tax. Find it with labelled prints.
- Use
f"{value = }"on three variables. Compare with unlabelled prints. - Create a bug caused by a trailing space in a string comparison. Find it with
!r. - Put
breakpoint()in a function, run it, and usen,pandc. Evaluate an expression at the prompt. - Set a breakpoint in VS Code and step through with F10 and F11. Note where they differ.
- Set a conditional breakpoint that only fires on the fiftieth iteration.
- Take a function of 30 lines producing a wrong answer and bisect it with three prints.
- Explain a piece of your own code out loud, line by line, to nobody. Note anything you had assumed without checking.
- Write a bug where a function returns
Nonebecause of a missingreturn, then diagnose it purely from the resultingTypeError.
That is module six. You can read a traceback, recognise the common errors, handle failure on purpose, refuse bad input properly, and find bugs that do not announce themselves.
Next module: files and data — reading and writing real files, and working with JSON and CSV, which is where your programs start handling data that outlives them.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship