Debugging Like a Pro
Advanced · 19 min read · ▶ live playground · ✦ checkpoint
The fastest debugger is not a tool. It is a question: "What would have to be true for this bug to happen?" Debugging like a pro means reading the evidence Python gives you, pausing the program at the right spot, and testing one guess at a time.
Read the traceback from the bottom up
A traceback is Python's report of the active calls that led to an exception. Think of it as a stack of open recipe cards: your main script starts one card, a function call puts another card on top, and the crash happens on the top card Python was reading.
Here is a tiny score report with a real bug:
def average_score(scores):
total = sum(scores)
return total / len(scores)
team_scores = []
print(f"Average: {average_score(team_scores):.1f}")In a terminal, the traceback has this shape. The folder path is shortened here; your terminal prints the full path.
$ python score_report.py
Traceback (most recent call last):
File ".../score_report.py", line 7, in <module>
print(f"Average: {average_score(team_scores):.1f}")
~~~~~~~~~~~~~^^^^^^^^^^^^^
File ".../score_report.py", line 3, in average_score
return total / len(scores)
~~~~~~^~~~~~~~~~~~~
ZeroDivisionError: division by zeroRead it in three passes. A frame is one active call listed inside the traceback.
- Start with the last line:
ZeroDivisionError: division by zero. That names the kind of failure and the message. - Move one frame up: line 3 inside
average_scoretriedtotal / len(scores). - Move higher: line 7 called
average_score(team_scores), andteam_scoreswas an empty list.
The fix is not "make the error go away." The fix is "decide what an empty score list means."
def average_score(scores):
if not scores:
return None
return sum(scores) / len(scores)
team_scores = []
average = average_score(team_scores)
if average is None:
print("No scores yet")
else:
print(f"Average: {average:.1f}")No scores yet
Print debugging has a ceiling
Print debugging means adding temporary print() calls to expose values while the program runs. It is often the right first move because it is fast, visible, and works anywhere.
Use it like a flashlight, not like confetti. Put the print beside the decision you do not trust, and print both the value and its type.
def label_for_order(customer, city, paid):
print("DEBUG paid:", paid, type(paid))
if paid:
return f"{customer} ({city}) - ship today"
return f"{customer} ({city}) - wait for payment"
orders = [
("Mina", "Pune", "no"),
("Ravi", "Delhi", "yes"),
]
for customer, city, paid in orders:
print(label_for_order(customer, city, paid))DEBUG paid: no <class 'str'>Mina (Pune) - ship todayDEBUG paid: yes <class 'str'>Ravi (Delhi) - ship today
The bug is not in the f-string. It is in the condition. Both "no" and "yes" are non-empty strings, and non-empty strings are truthy.
Try it - debug one wrong decision
Run this code once. It runs clean, but one result is wrong. Add one print near the if, predict what it will show, then change the condition so only "yes" ships today.
Use a debugger when prints get noisy
A debugger is a tool that pauses a running program so you can inspect names, move one line at a time, and test expressions before continuing. A breakpoint is a pause point you choose before running. Python ships with pdb, the Python debugger, and the built-in breakpoint() function opens it at that line in a normal terminal.
The debugger version of the shipping bug would put the pause right before the decision:
def label_for_order(customer, city, paid_text):
breakpoint()
if paid_text:
return f"{customer} ({city}) - ship today"
return f"{customer} ({city}) - wait for payment"
orders = [
("Mina", "Pune", "no"),
("Ravi", "Delhi", "yes"),
]
for customer, city, paid_text in orders:
print(label_for_order(customer, city, paid_text))When the program pauses, pdb gives you a (Pdb) prompt. You can ask questions before the if runs.
(Pdb) paid_text
'no'
(Pdb) type(paid_text)
<class 'str'>
(Pdb) bool(paid_text)
True
(Pdb) nn means next, a debugger command that runs the current line and pauses again at the next line. Other useful commands:
smeans step into, which enters a function call so you can debug inside it.cmeans continue, which keeps running until the next breakpoint or the end.qmeans quit, which stops the debug session.
Do not leave breakpoint() calls in code you share. They are pause buttons for your local investigation.
VS Code shows the same ideas visually
VS Code's debugger is not a different kind of thinking. It is the same pause-and-inspect loop with buttons and panels.
In VS Code, a breakpoint is usually shown as a red dot beside the line number. A watch expression is an expression you ask the debugger to keep showing, like paid_text == "yes". The call stack is the stack of active function calls, the same open-recipe-card stack a traceback prints after a crash.
The main controls map directly to pdb ideas:
- Step over runs the next line without entering any function call on that line.
- Step into enters the function call on that line.
- Step out finishes the current function and returns to its caller.
- Continue runs until the next breakpoint.
Use VS Code when the program has several moving parts and you need to watch names change over time. Use pdb when you are already in a terminal or on a machine without editor setup. Use prints when one or two values will answer the question faster.
Debug with hypotheses, not hope
A hypothesis is a testable guess about why the program behaves that way. Good debugging makes the guess small enough that one print, one breakpoint, or one changed input can test it.
Bad loop:
- "This function is broken."
- "Python is acting weird."
- "Maybe I should rewrite the whole thing."
Useful loop:
- Name the wrong behavior: "Mina ships today even though her paid text is
no." - Form a guess: "
paid_textis a string, and theiftests truthiness instead of equality." - Test the guess: print or watch
paid_text,type(paid_text), andpaid_text == "yes". - Make the smallest fix: compare the text explicitly.
- Rerun the same case.
def label_for_order(customer, city, paid_text):
paid = paid_text == "yes"
if paid:
return f"{customer} ({city}) - ship today"
return f"{customer} ({city}) - wait for payment"
orders = [
("Mina", "Pune", "no"),
("Ravi", "Delhi", "yes"),
]
for customer, city, paid_text in orders:
print(label_for_order(customer, city, paid_text))Mina (Pune) - wait for paymentRavi (Delhi) - ship today
One final habit: when you fix a bug, keep the example that found it. In the next lesson, you will learn how logging leaves useful evidence in real programs without covering the code in temporary prints.
Checkpoint
Answer all three to mark this lesson complete