Debugging Techniques and Tools
Reading a traceback the right way, the real limits of print debugging, pdb, VS Code's debugger, and a full worked debugging session.
This module opens Phase 6 — Production & Career Readiness, the final phase of this curriculum. Everything up to this point has been about writing Python that works. Phase 6 is about the skills that turn "it works on my machine" into a professional habit: debugging systematically instead of guessing, logging instead of printing, packaging instead of copy-pasting scripts between machines, profiling before optimizing, and eventually walking into a technical interview and explaining all of it clearly. Debugging comes first because it is the single most used skill in this entire phase — you will lean on it while learning every module that follows.
Bottom to Top — Not Top to Bottom
Almost every beginner reads a Python traceback the way they read everything else in English — top to bottom. That is exactly backwards, and it is the single biggest reason new engineers stare at a wall of text and freeze. A traceback is a call stack, printed in the order Python walked into each function to reach the line that finally failed. The bottom of the traceback is where the actual failure happened. Everything above it is just the trail of function calls that led there.
Traceback (most recent call last):
File "app.py", line 22, in <module>
total = calculate_order_total(cart)
File "app.py", line 15, in calculate_order_total
line_total = compute_line_item(item)
File "app.py", line 9, in compute_line_item
return item["price"] * item["quantity"]
KeyError: 'price'Read it from the bottom up. KeyError: 'price' is the actual exception — the program tried to access a dictionary key called "price" that did not exist. The line directly above it, line 9, in compute_line_item, is exactly where that lookup happened. Everything further up — compute_line_item was called from calculate_order_total, which was called from the top-level module — is context, useful for understanding why that function ran with bad data, but not where the bug actually lives. A beginner who reads top-down often starts investigating calculate_order_total first, when the real fix belongs three lines lower.
The exception type tells you the category of bug before you read anything else
Before reading a single line of your own code, read the exception's name. It is a direct classification of what went wrong, and recognizing the category instantly narrows your search. A KeyError means a dict lookup failed. A TypeError means an operation was applied to a value of the wrong type. An IndexError means a sequence was accessed out of bounds. An AttributeError means you called a method or accessed a field that does not exist on that object — very often because the object is unexpectedly None. Experienced engineers develop an almost reflexive mapping from exception type to likely cause, and building that reflex starts with deliberately reading the exception name first, every single time, instead of skimming straight past it to the message.
Traceback (most recent call last):
File "pipeline.py", line 40, in <module>
run_pipeline()
File "pipeline.py", line 31, in run_pipeline
records = [transform(r) for r in load_records()]
File "pipeline.py", line 31, in <listcomp>
records = [transform(r) for r in load_records()]
File "pipeline.py", line 18, in transform
return record["id"].strip().upper()
AttributeError: 'int' object has no attribute 'strip'Here the bottom frame is the real story: record["id"] returned an int, not a str, so calling .strip() on it fails. The fix is not in run_pipeline — it is either in transform (handle non-string IDs) or, more likely, upstream in whatever produced records with an integer id field when a string was expected. A long traceback is not a sign of a complicated bug; it is just Python being generous with context. The fix is almost always concentrated in the last one or two frames.
print() Is a Legitimate First Tool — Until It Isn't
There is a strain of advice online that treats print() debugging as something amateur engineers do and real engineers don't. That is not true. Sprinkling print() calls to check a value at a specific point is fast, requires no setup, and is genuinely the right tool for a huge fraction of bugs — especially small scripts and quick "what is actually in this variable right now" questions. You already used this technique constantly in the Formatting module (Module 10) when inspecting values with f-strings during development.
def calculate_discount(price, percent):
print(f"DEBUG: price={price!r}, percent={percent!r}")
return price * (1 - percent / 100)The problem is not that print() is wrong — it is that it stops scaling the moment a bug gets even slightly more complex. Four real limits show up constantly:
1. You have to GUESS which variables matter before you run the code —
if you guessed wrong, you edit the file and re-run, over and over.
2. It only shows you a snapshot at one instant — it can't show you
the full call stack, or let you inspect a value that changes across
many loop iterations without flooding your terminal.
3. You have to remember to delete every debug print before committing —
and "print statements left in production code" is a real, common
code review complaint.
4. It can't PAUSE the program and let you explore interactively —
you only see exactly what you thought to print.print() first, for a bug you have a strong guess about, in a small function. The moment you have printed three or four things and still don't understand what is happening — or the bug depends on how a value changes over dozens of loop iterations — stop guessing and reach for a real debugger instead, covered in Part 03 and Part 04 below.A slightly better middle ground: the logging module
One genuine upgrade from raw print() that costs almost nothing is Python's built-in logging module, which the very next module in this track covers in full depth. For now, it's worth knowing it exists as the natural next step once debug-prints start accumulating in a file you intend to keep.
pdb — Pausing Your Program and Looking Around
pdb is Python's built-in interactive debugger. Instead of guessing which values to print ahead of time, it pauses your program at a chosen line and drops you into a live prompt where you can inspect any variable in scope, run arbitrary expressions, and step through execution one line at a time. The easiest way to trigger it is the built-in breakpoint() function, available since Python 3.7 — call it anywhere in your code and execution stops there.
def calculate_discount(price, percent):
breakpoint() # execution pauses HERE, on this exact line
return price * (1 - percent / 100)
calculate_discount(100, 150)> app.py(3)calculate_discount()
-> return price * (1 - percent / 100)
(Pdb) From that prompt, you are inside a live Python session with full access to every local variable. The core commands you need cover almost everything:
n (next) — run the current line, stop at the next one in THIS function
s (step) — step INTO a function call on the current line
c (continue) — resume normal execution until the next breakpoint (or the end)
p (print) — evaluate and print any expression: p price, p percent > 100
l (list) — show the source code around the current line
q (quit) — abort the debugging session entirely
w (where) — show the current call stack, like a live tracebackThe distinction between n and s trips people up at first: n treats a function call on the current line as a black box and just moves to the next line in the current function; s actually steps inside that function call, letting you watch its internals execute too. Use n when you trust the function being called and just want to keep moving; use s the moment you suspect the bug is inside it.
import pdb
def calculate_discount(price, percent):
pdb.set_trace() # identical effect to breakpoint(), older API
return price * (1 - percent / 100)breakpoint() and pdb.set_trace() do the same thing — breakpoint() is just newer, requires no import, and is the version you will see in modern code. You will still run into pdb.set_trace() reading older codebases, so it is worth recognizing both.Post-mortem debugging — inspecting a crash after the fact
You do not always have to predict where a bug is and place a breakpoint() ahead of time. Running a script with python -m pdb -c continue app.py lets it run normally and drops you into the debugger automatically at the exact moment it crashes, with the full stack still available to inspect — genuinely useful for an intermittent bug you cannot easily reproduce on demand.
VS Code's Debugger — pdb's Power, Without the Prompt
Back in Module 01, you set up VS Code with the Python extension as this track's recommended editor. That same extension ships a full graphical debugger built directly on top of the same debugging protocol pdb uses conceptually — but instead of typing commands at a text prompt, you click in the left margin next to a line number to set a breakpoint, then run your file with the debugger (the "Run and Debug" panel, or F5) instead of just running it normally.
When execution reaches your breakpoint, the program pauses, and VS Code shows you, all at once, in dedicated panels: every local variable currently in scope and its live value, the full call stack (every function that led to this point, clickable to jump between frames), and a watch panel where you can type any expression and have it continuously re-evaluated as you step through the code — genuinely more convenient than repeatedly typing p commands in pdb for the same variable.
1. Click in the gutter to the left of a line number — a red dot appears (a breakpoint).
2. Open "Run and Debug" (or press F5). Your script runs normally until it hits that line.
3. Execution pauses. The Variables panel shows every local variable and its current value.
4. Use the debug toolbar's step controls — visually identical to pdb's n / s / c:
Step Over = n Step Into = s Continue = c
5. Add an expression to the Watch panel — e.g. "len(cart_items)" —
and watch it update automatically as you step through the loop.
6. The Call Stack panel shows exactly which functions led here, clickable
to inspect each frame's own local variables.item["id"] == 4821 — instead of stopping on every single iteration of a loop that runs ten thousand times. This is genuinely hard to replicate quickly with pdb alone, and it is the single biggest reason to reach for the IDE debugger once a bug lives inside a loop over real data.Neither tool makes the other obsolete. pdb works anywhere Python runs — over SSH on a remote server with no graphical interface at all, inside a Docker container, in a CI pipeline. The IDE debugger is faster and more visual for local development. Most working engineers default to the IDE debugger day to day and fall back to pdb the moment they are debugging something running somewhere without a GUI attached.
Explaining the Bug Out Loud, Line by Line
Rubber duck debugging sounds like a joke the first time you hear it, and it is a genuinely effective technique that experienced engineers still use constantly. The method: get an object — traditionally a literal rubber duck, though a coworker, a pet, or an empty chair works equally well — and explain your code to it, out loud, line by line, as if it understands nothing and you need to walk it through every single decision your code makes.
The mechanism behind why this works is not magic. Reading code silently lets your brain skim — it pattern-matches what it expects the code to say rather than what it actually says, especially in code you wrote yourself and already have a mental model of. Forcing yourself to articulate each line out loud, in full sentences, interrupts that autopilot. You are forced to state your assumptions explicitly — "and then this loop goes through each item in the cart, and for each one it looks up the price in the price dictionary" — and it is extremely common to catch the bug mid-sentence, the moment you say an assumption out loud that turns out to be false the instant you hear yourself say it.
Cutting the Search Space in Half, Every Time
When a bug lives somewhere in a large amount of code and you genuinely have no idea where to start, the systematic strategy is the same one you would use to find a single wrong entry in a sorted list: don't scan linearly from the top — cut the search space in half, repeatedly. Disable, comment out, or bypass roughly half of the suspect code, and check whether the bug still happens. If it does, the bug is in the half you kept. If it doesn't, the bug is in the half you removed. Repeat on the remaining half.
# You don't know which of 6 processing steps is corrupting the data.
# Instead of reading all 6 top to bottom, bisect:
def run_pipeline(records):
records = load_and_clean(records)
records = deduplicate(records)
records = enrich_with_metadata(records) # <- comment out THIS and everything below
# records = apply_business_rules(records)
# records = compute_aggregates(records)
# records = export(records)
return records
# Check the output after step 2. Correct? The bug is in step 3, 4, 5, or 6 — bisect again.
# Wrong already? The bug is in step 1 or 2 — you've eliminated 4 of 6 steps in one check.This technique generalizes far beyond commenting out function calls. Git itself has a purpose-built command for exactly this idea at a larger scale: git bisect automatically checks out commits at the midpoint between a known-good and known-bad commit, asks you to test and mark each one good or bad, and converges on the exact commit that introduced a regression in log₂(n) steps rather than checking every commit one by one. If a bug appeared sometime in the last 200 commits and you have no idea which one caused it, git bisect start followed by marking a known-good and known-bad commit will typically find the culprit in 7 or 8 test runs instead of up to 200.
pdb or the IDE debugger from Parts 03–04 to actually inspect what is happening at that specific point.Start to Finish — Debugging a Broken Discount Function
Here is a realistic bug, debugged the way an engineer actually would, combining several of the techniques above rather than treating them as separate, disconnected tools.
def apply_discounts(cart_items, discount_codes):
total = 0
for item in cart_items:
price = item["price"]
for code in discount_codes:
if code["applies_to"] == item["category"]:
price = price - (price * code["percent_off"] / 100)
total += price
return totalStep 1 — read the exception, if there is one. There isn't. This is worse than a crash: the function runs and returns a plausible-looking number that is simply wrong for some inputs. No traceback to read means Part 01's technique doesn't apply yet — this calls for actively inspecting values, not reading an error.
Step 2 — form a hypothesis before touching a debugger. "Negative totals" for a discount function strongly suggests a discount is being applied more than once, or a percentage is being misread (e.g. 150 meant as a display value being used directly instead of being validated). Rather than guessing blindly, the engineer reaches for breakpoint() right where discounts are applied.
for item in cart_items:
price = item["price"]
for code in discount_codes:
if code["applies_to"] == item["category"]:
breakpoint()
price = price - (price * code["percent_off"] / 100)
total += priceStep 3 — inspect at the pause. Running the failing input and hitting c to continue through each pause, the engineer types p code at every stop. Two codes both report "applies_to": "electronics" for the same item — one intended as a one-time promo, one as a permanent category discount — and both apply to the exact same item in the same inner loop, stacking on top of each other with no limit.
Step 4 — confirm the hypothesis with a targeted print, once it's cheap to check. With the real cause understood, a quick print(f"item={item['name']!r} matched {len([c for c in discount_codes if c['applies_to'] == item['category']])} discount codes") across the full cart confirms three items match more than one code — the actual bug, not just the case that happened to be paused on.
def apply_discounts(cart_items, discount_codes):
total = 0
for item in cart_items:
price = item["price"]
matching = [c for c in discount_codes if c["applies_to"] == item["category"]]
if matching:
best_discount = max(c["percent_off"] for c in matching)
price = price - (price * best_discount / 100)
total += price
return totalNotice the shape of the whole session: read for an exception first (there wasn't one, so that step took five seconds and moved on), form a real hypothesis before touching any tool, use breakpoint() to inspect state at the exact suspect line rather than guessing blindly, and confirm the root cause with a broader check before writing the fix. This is the actual shape of professional debugging — not one technique used in isolation, but a short, disciplined sequence of narrowing steps.
An On-Call Page at a Denver Delivery-Logistics Company
A route-assignment script, running as a scheduled job every night, crashes at 2 a.m. and pages the on-call engineer — a relatively junior hire, three months into the job, first solo page. Tomorrow's delivery routes will not generate unless this is fixed before dispatch opens at 6 a.m.
What the traceback actually says
Traceback (most recent call last):
File "assign_routes.py", line 88, in <module>
main()
File "assign_routes.py", line 71, in main
routes = build_routes(drivers, stops)
File "assign_routes.py", line 44, in build_routes
driver = pick_driver(available_drivers, stop.zone)
File "assign_routes.py", line 29, in pick_driver
return sorted(drivers, key=lambda d: d.distance_to(zone))[0]
IndexError: list index out of rangeHalf-asleep, the engineer's first instinct is to start reading from main() at the top of the traceback. Remembering Part 01 of this module, they force themselves to start at the bottom instead: IndexError: list index out of range, on the line sorted(...)[0]. Indexing [0] into an empty sorted list is the exact cause — drivers was empty when pick_driver ran, meaning every available driver had already been assigned before this particular stop was reached.
Confirming it without guessing
Rather than editing the file blindly and re-running the full nightly job (which takes eleven minutes end to end — an expensive guess-and-check loop at 2 a.m.), the engineer drops a breakpoint() directly above the failing line (Part 03) and re-runs just build_routes against a saved copy of last night's input data. At the pause, p len(available_drivers), p stop.zone, and p [d.zone for d in drivers] confirm it in under a minute: eight drivers were hired last week for a new zone that the stop-assignment logic never accounted for, so every stop in that zone exhausted the (wrongly empty) driver pool for it.
The fix — falling back to the nearest adjacent zone's drivers when a zone has none assigned yet — ships before 4 a.m., routes generate on time, and the engineer writes up the incident the next morning. The lesson they take from it, and repeat to the next new hire on their team months later, is almost word for word Part 01 of this module: read the traceback from the bottom, and confirm your hypothesis with a real pause-and-inspect before editing code at 2 a.m. under pressure.
Four Misconceptions About Debugging
5 Interview Questions — With Complete Answers
Debugging Mistakes Beginners Make Constantly
Errors and Symptoms You Will Hit While Debugging — And Exactly Why
🎯 Key Takeaways
- ✓Read a traceback from the bottom up — that is where the actual exception occurred. Everything above it is call-chain context.
- ✓The exception type (KeyError, TypeError, IndexError, AttributeError...) classifies the bug before you read a single line of your own code.
- ✓print() debugging is legitimate and fast for small, localized bugs — the skill is recognizing when to switch to a real debugger.
- ✓breakpoint() (or the older pdb.set_trace()) pauses execution and drops you into an interactive prompt. Core commands: n, s, c, p, l, w.
- ✓VS Code's built-in debugger gives you the same power visually — breakpoints, a variables panel, a watch panel, and a clickable call stack — plus conditional breakpoints, which are hard to replicate quickly in plain pdb.
- ✓Rubber duck debugging works by forcing you to state assumptions out loud, interrupting the autopilot skimming that happens when reading code silently.
- ✓Binary search debugging (and git bisect for regressions) is the right tool when you have no localized hypothesis yet — cut the search space in half repeatedly.
- ✓A real debugging session combines these tools in sequence: read the error, form a hypothesis, confirm it with a pause-and-inspect, then fix — not one technique in isolation.
What comes next
Module 40 covers logging — why print() genuinely is not logging, the logging module, log levels, and configuring a logger the way real production services do it.
Module 40 → Logging Best PracticesDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.