Metadata Card
- Prerequisites: Ch3 (Git Basics)
- Estimated time: 40 min
- Core difficulty: (Advanced)
- Reading mode: High focus
- Completion marker: Can independently use VS Code or IntelliJ to set breakpoints, step through code, watch variables, and analyze the call stack
Your Progress
You're still in the workshop before setting out. Git's time machine taught you how to travel back and forth, but the code runs and produces wrong results — it doesn't crash, it just calculates wrong. Or worse — it's sometimes right, sometimes wrong, depending on its mood.
You stare at the code on the screen — line by line. "This looks right. Why does it come out wrong?"
What you need isn't more focused eyes — it's a microscope.
Your Task
You wrote a function to process a work order list. It takes a list, sorts by weight, filters out defective records, and returns the top ten items. The logic looks clear — but every time it runs, the top ten always includes some items with zero weight. You added a bunch of print() statements everywhere, the screen fills with output you have to scroll through — like looking for a dropped needle in a dark room.
You need a way to freeze the program mid-execution and ask it: "What are your variables worth right now? Did this line of code actually run?"
That's what the debugger does.
Chapter Layers
- Required reading: Purpose of breakpoints, single-step execution (Step Over / Step Into), observing variables through the VARIABLES panel, tracing call sources through the CALL STACK panel
- Optional reading: Conditional breakpoints, evaluating and modifying variables in the debug console, log points (Logpoint/Tracepoint)
- Advanced: GDB command-line debugging (only for C/C++ developers), remote debugging
This chapter will NOT require you to understand
- GDB debugging — if you don't write C/C++, no need to learn now
- Remote debugging (attaching to a server process)
- Disassembly-level debugging (Disassembly View)
The Breakthrough · Tracing the Origins
First Battle: print() Till Your Hands Hurt
You just forged a dagger blade. After quenching, you pick it up and check the weight — it's wrong. The dimensions aren't off — it's the material ratio.
You stare at the blade for a long time. On one side, you recall the forging steps — clear steps, standard technique. "This can't be wrong..." you mutter.
Then you start scraping iron filings bit by bit into the scale pan. The table is covered in filings; you sort through them one by one. "Hmm, this one weighs 100 grams, the theoretical should be 200... Wait, did I misremember or is the scale broken?"
Filings scattered across the table — like looking for a dropped sewing needle in a dark workshop. You're practically poking holes in your notebook.
"This looks right! Why does it come out wrong?"
Suppose this is your buggy function:
# material_checker.py
def get_qualified_items(items, n=10):
valid = []
for it in items:
if it["weight"] > 0:
valid.append(it)
sorted_items = sorted(valid, key=lambda x: x["weight"], reverse=True)
return sorted_items[:n]
items = [
{"id": 1, "weight": 100},
{"id": 2, "weight": 0}, # scrap — weight is 0
{"id": 3, "weight": 200},
{"id": 4, "weight": -50}, # scrap — negative weight, but can the condition catch it?
]
print(get_qualified_items(items, 2))Language: Python 3 How to run: python material_checker.pyExpected output (theoretically): Top 2 qualified items by weight Actual output (with logic bug): Might include the -50 item, because the condition only checks > 0
Traditional approach: stuff a bunch of print() statements in to see what the data looks like at each step. But every time you change the code, you have to restart the entire program, and after debugging you have to remember to delete all those print() statements — or they pollute your code.
There's a better way — freeze the program right at runtime and step through line by line to observe.
First Skill Acquired: Breakpoints
"I'm so tired of the cycle of scraping filings, recording readings, scraping filings again." You rest your head on the workbench. "If only I could stop the forge at a certain moment and see what the furnace temperature actually is..."
The workshop master appears behind you. "Then stop it and observe."
"What?"
He reaches around you and points at an instrument panel — a red marker lights up. "This is called a breakpoint. You tell the measuring tool: 'When the operation reaches this point, stop and let me observe.'"
A breakpoint is where you tell the debugger: "When this line of code is reached, stop."
In VS Code or IntelliJ IDEA, you simply click the blank area left of the line number — a red dot appears.
Suppose you open material_checker.py in VS Code and click to the left of if it["weight"] > 0::
# The debugger will pause here every time the loop reaches this line
for it in items:
● if it["weight"] > 0: # ← Red dot = breakpoint
valid.append(it)How to run: Press F5 or click "Run and Debug" in VS Code's left sidebar → select "Python File" Expected effect: The editor will stop on that line, the line is highlighted, the program hasn't exited — it's waiting for your next instruction.
When the program stops, a debug toolbar appears:
▶ Continue | ⤵ Step Into | ⤴ Step Out | Restart | StopFrom left to right:
- Continue (F5): Program continues until it hits the next breakpoint or finishes
- Step Into (F11): Go inside the function called on the current line
- Step Over (F10): Execute the current line and stop on the next (don't go inside the function)
- Step Out (Shift+F11): Jump out of the current function back to the caller
- Restart/Stop
Now you can "crawl" along the timeline with these buttons — advance one line at a time and watch the variables change.
See What's Happening to Your Variables
When the breakpoint stops, VS Code automatically shows a VARIABLES panel on the left, listing all variables in the current scope with their values and types:
VARIABLES
Locals
it: {'id': 1, 'weight': 100}
items: [{'id': 1, 'weight': 100}, ...]
valid: []
n: 10You don't need to guess. You can see with your own eyes what it["weight"] equals at this exact moment. You can even type an expression in the WATCH panel, like it["weight"] * 2, and the debugger calculates the result in real time.
This is the biggest advantage of a debugger over print(): It's alive. You don't have to plan ahead what to print — you can look at anything you want at the moment the breakpoint hits.
First Battle Postmortem
Let's go back to that it["weight"] > 0 filter. Your original code filters out items with weight <= 0. But should weight = -50 count as a qualified item?
If you're not running the loop logic in your head, but running it in the debugger step by step, you'll immediately see — when it reaches {"id": 4, "weight": -50}, if it["weight"] > 0 evaluates to False, so it doesn't go into the valid array.
But wait — if your intent was "weight >= 0 counts as qualified" (0 is also unqualified since an empty material is not a workpiece), then the condition should be >= 0, not > 0. With >, a weight = 0 item would also get through.
You can notice this issue with just one breakpoint pause — look at the value of it["weight"], then see which branch the program took. With print(), you'd have to scatter logs everywhere and re-run. The debugger lets you participate in the program's execution in real time.
Second Battle: A Bug That Only Appears Under Specific Conditions
After fixing the first casting defect, you confidently restart the forge.
The finished product comes out — but there's still an unexpected air pocket in it. You check the casting records and find that a batch of material has number 99999 but label 0.
"What's going on..." You try to set an observation point. But the problem is — your forging process goes through ten thousand steps, and every step stops at the observation point. You'd have to pull the lever ten thousand times to see that special step.
"I can't check them all one by one..."
The previous problem is fixed, but now you've found a new one: a batch of material numbered 99999 with a label of 0. You suspect the material cabinet records are wrong — but this situation is rare. Your observation point keeps stopping on things it shouldn't; the process hasn't reached that special batch yet when something else interrupts it.
You don't need to stop on every element in the loop — you can stop only under specific conditions.
In VS Code, right-click the breakpoint → select "Edit Breakpoint..." (or right-click in IntelliJ):
for it in items:
● if it["weight"] == 99999: # ← Only stop when weight equals 99999
valid.append(it)How to set: Right-click the red dot, enter the condition expression it["weight"] == 99999
Now press F5 to continue running — the program runs smoothly through weight = 100, weight = 200 without stopping. Until it reaches the weight = 99999 record — snap, it stops.
In the VARIABLES panel, you see:
it: {'id': 0, 'weight': 99999}id = 0 — the item's ID generator is indeed broken. Without conditional breakpoints, you'd need 10,000 print() statements or furiously press F5 to skip past uninteresting loop iterations.
Essence of conditional breakpoints: It's still the same breakpoint, but with a "gatekeeper." Every time the program passes this line, it checks the condition — stops only if
true, otherwise passes right through. This is side-effect-free observation — minimal performance impact (just a small condition check), no state impact.
Third Battle: You Call Me, I Call It — Who Called Who?
A new problem emerges. Your quenching pool's temperature reading returns 0, but you know it shouldn't be 0. You set an observation point on the temperature controller — it stops. The gauge looks fine, the connections look fine.
"Then where's the problem?" you scratch your head. "This temperature controller was operated by someone — who operated it? What parameters were passed in?"
You find your process step on the workshop record board, but you can't tell who summoned you to this step. It's like someone tapped your shoulder from behind — you turn around, and no one's there.
After fixing the temperature probe, the test passes. But when another workstation calls the quenching process, it fails mysteriously. You can see your process name on the record board, but who operated on it before?
You need to trace back through the chain of operations — see who brought me here.
Suppose there's this code:
# forge_calculator.py
def calc_material_cost(item_id, weight):
discount = get_alloy_discount(weight)
print(f"Processing item {item_id}, material cost after discount: {weight - discount}")
def get_alloy_discount(weight):
ratio = get_discount_ratio()
return weight * ratio
def get_discount_ratio():
return 0 # Always 0 — wait, that means there's never any discount?You set a breakpoint inside get_alloy_discount. The program stops. On your screen, a CALL STACK panel appears:
CALL STACK
get_alloy_discount (forge_calculator.py:9)
calc_material_cost (forge_calculator.py:5)
<module> (forge_calculator.py:14)Read from bottom to top.
- Bottom line
<module>is the script entry — you ran this file directly - Above that,
calc_material_costcalledget_alloy_discount - Top line is where you're currently stopped —
get_alloy_discount
It's like tracing the material source in the workshop: you know this piece is on the processing table (current function), you want to know "who handed it over" (call chain). The call stack is the chain of hands behind you.
Your eyes go back to get_alloy_discount — you see weight = 100, ratio = 0. Because get_discount_ratio() returned 0, the discount is 0. But is this what you intended? Maybe get_discount_ratio() itself has a bug, or maybe you just forgot to implement the logic?
You double-click calc_material_cost in the call stack — the debugger takes you to the caller's line, and all variable states are preserved. You can observe the caller's variables in that frame. This is the debugger's "time travel" ability.
Fourth Battle: Let Me Calculate This Expression
You can see the readings on the measuring tool — Iron ingot weight = 100, Impurity ratio = 0.1. But your brain can't stop calculating: "Iron weight × (1 - impurity ratio) is 90 jin, but what if the impurity doubles? 180? No, wait..."
You calculate it in your head over and over, always feeling like you got it wrong. But you don't want to add another temporary scratch page to the workshop notebook — you'd have to tear it out anyway.
"If only I could calculate it right here..." you stare at the console. "Let the abacus do it, not me."
You see the variable values, but you want to see more complex derived results — like what weight * (1 - ratio) equals under specific conditions. You don't want to add a new variable to the code just to hold it.
In the WATCH panel, type any expression:
WATCH
weight * (1 - ratio): 100.0
round(weight * (1 - ratio), 2): 100.0
f"Material cost after discount: {weight - weight * ratio}": "Material cost after discount: 100.0"Language: Any IDE that supports debugging How to run: When stopped at a breakpoint, type an expression in the WATCH panel Effect: The debugger evaluates it in the current context and shows the result
You can also select a piece of code in the stopped editor area, right-click → "Evaluate in Console" (or press Ctrl+Shift+Enter to open Debug Console):
>>> weight * (1 - ratio)
100.0
>>> [it["weight"] for it in items if it["weight"] > 100]
[200, 99999]You can write Python expressions directly in the debug console, including list comprehensions, function calls, and even modifying variable values — the program is still running, but you can already perform "surgery" on it.
Be careful: Modifying variables in the debug console will affect the program's subsequent execution. This isn't a simulation — you're actually changing values in memory.
Advanced Adventure: Debugging Isn't Just IDE Techniques
Logpoints
Sometimes you don't want to stop the program — you just want to see how a variable changes in each iteration, but you're too lazy to write print(). VS Code and IntelliJ both support Logpoints (Tracepoints).
Right-click a breakpoint → "Log Message" (VS Code) Or right-click a breakpoint → "More" → check "Log message to console" (IntelliJ)
● # Logpoint: don't pause, just log
# Log message: "Current item: {m['id']} weight: {m['weight']}"Every time the loop passes this line, the program doesn't stop (like a regular breakpoint), but the debug console prints:
Current item: 1 weight: 100
Current item: 2 weight: 0
Current item: 3 weight: 200This is a "zero-intrusion" debugging method — you don't need to delete these logs because they aren't code; they're IDE configuration. Turn off debug mode and they disappear.
If you don't write C/C++, the following content can wait. Jump to the next section.
Appendix: GDB — Another Kind of Debugger
If you later work with C/C++, the debugger is called GDB (GNU Debugger). The concepts are the same as an IDE debugger, but in command-line form:
# Compile with -g flag (includes debug info)
gcc -g myprog.c -o myprog
# Start debugging
gdb ./myprog
# Inside GDB
(gdb) break main # Set breakpoint at main function
(gdb) run # Run
(gdb) print x # View variable x
(gdb) next # Step over
(gdb) step # Step into
(gdb) backtrace # View call stack (equivalent to CALL STACK)
(gdb) quitLanguage: Bash / GDB How to run: g++ -g program.cpp -o program && gdb ./programExpected output (starting):
Reading symbols from ./program...
(gdb)GDB isn't as visual as an IDE debugger, but it's irreplaceable in embedded systems and server debugging — when you can only SSH into a server without a desktop, GDB is your only option. But this doesn't concern you right now. Come back when you actually need to write C/C++.
Common Pitfalls
Story One: I spent a whole night debugging, only to find the breakpoint was in the wrong place
Once I encountered a weird problem — the program stopped moving at a certain point every time, but didn't crash. I spent two hours adding breakpoints, stepping through, watching variables — nothing wrong. Eventually I found that my breakpoint was on a comment line. The IDE lets you set a breakpoint on a comment — but it can never be executed, so the program never stops there.
The debugger won't tell you "hey, this line is a comment." It silently respects your decision, even if it's invalid. So you keep waiting for a stop that will never come.
Lesson: Make sure your breakpoint is on an executable code line. A valid breakpoint's red dot is solid; if it becomes hollow or shows a question mark, the IDE knows you placed it in an invalid position.
Story Two: Optimized code vs source code line numbers
C/C++ code with compiler optimizations (-O2) might reorder instructions, inline functions, or delete unused variables. Your breakpoint might be "optimized away" — the program runs right past it without stopping because that line doesn't exist in the compiled binary.
Solution: Use -O0 -g for development builds, -O2 only for release builds. Never debug on an optimized binary.
Final Challenge
Warm-up (5 min, required)
- Open any Python/Java project in VS Code or IntelliJ
- Set a breakpoint inside a function you know
- Run in debug mode (F5 / Debug run)
- Step Over (F10 / Step Over) at least 5 lines
- Open the VARIABLES panel to observe variable changes
Challenge (30 min, optional)
Reproduce a buggy recursive function and use the debugger to locate the problem:
# factorial_debug.py
def factorial(n):
# This version has a bug
if n == 0: # Should be n == 1 or n <= 1
return 1
return n * factorial(n - 1)
print(factorial(5))Tasks:
- Set a breakpoint on
return n * factorial(n - 1) - Observe call stack changes — each recursive call pushes one more frame
- Add a conditional breakpoint on
n == 2, stopping only when recursion depth reaches 2 - In the WATCH panel, type
n * factorial(n - 1)— wait, why is it<error>? Because the recursive function hasn't returned yet, so the expression can't be evaluated
Troubleshooting
Scenario 1: You set a breakpoint, press F5, but the program just finishes without stopping. Diagnosis: Could be (a) the breakpoint is in an invalid position (comment, non-executable line); (b) you're running the wrong target file; (c) compiler optimizations eliminated the line. Solution: Confirm the red dot is solid, confirm you clicked the correct file, confirm the debug config selects the right entry point.
Scenario 2: When stepping in (F11), you jump into standard library internals (like the implementation of sorted()). Diagnosis: The IDE defaults to stepping into installed library code. Most of the time you don't need this. Solution: The debugger toolbar usually has a "Skip over library code" button (VS Code calls it "Just My Code"). In Java, IntelliJ enables this by default.
Checklist
- [ ] Understands the purpose of breakpoints — pause program execution at a specified line
- [ ] Can use conditional breakpoints to filter out unnecessary pauses
- [ ] Can observe variable values through VARIABLES / WATCH panel
- [ ] Can trace call sources through CALL STACK panel
- [ ] Knows the difference between Step Over and Step Into
- [ ] Can evaluate and modify variables in the debug console
- [ ] Knows that Logpoints can replace temporary print() debugging
Common Sticking Points
- "Step Into (F11) vs Step Over (F10) — which one do I use?"
- Step Over: Don't enter the function — execute the current line's function call completely and stop on the next line. If you don't care about
sorted()'s internals, use Step Over. - Step Into: Jump inside the function on the current line. If you want to see how
get_qualified_items's loop runs, use Step Into.
- "If I change a variable in the debug console, does the program actually change?"
- Yes. If you write
n = 100in the debug console, the program'snbecomes 100. This is a "cheat" mechanism for testing edge cases, but remember to change it back.
- "Why can't I see certain variables defined in upper modules?"
- The debugger only shows variables in the current stack frame (current scope). To see a caller's variables, click that frame in the CALL STACK panel.
- "Too many breakpoints — do I have to delete them every time?"
- No. You can disable breakpoints (uncheck the red dot) or clear all at once (VS Code: Debug panel → Breakpoints section → select all → delete).
No Need to Understand Now
- Remote Debugging (attaching to a server process — learn when you actually need to deploy remotely)
- Core Dump analysis — extreme crash scenarios
- Side-effect expressions in conditional breakpoints (calling functions that change state inside breakpoint conditions — too dangerous, almost never needed)
- Disassembly-level debugging (Disassembly View) — unless you write low-level C/assembly code
Traveler's Notes
The debugger is not a tool for errors — it's a tool for observation. print() is like speculation, "there might be a problem here"; a breakpoint is like eyewitness testimony, "I saw with my own eyes what it did." The call stack lets you see the causal chain of "who called who" — many bugs aren't the last line's fault, but whoever led the program down that path.
→ Preview of Next Stop
Debugger in hand, bugs recede into the distance. But your project is starting to depend on more and more external libraries — where does this code come from? How do you install it? How do you ensure the version on your computer matches the version on someone else's? Next chapter — package managers.