Chapter 05 · Week 5

Debugging Discipline

How do we find what is wrong with ourselves?

Chapter 5 — Debugging Discipline

“Search me, O God, and know my heart! Try me and know my thoughts! And see if there be any grievous way in me, and lead me in the way everlasting!” — Psalm 139:23–24

“It is not the bug that defeats you. It is the moment you stop forming hypotheses and start guessing.” — every senior engineer, eventually


Why This Matters

Three projects from now, you will hand a specification to an AI assistant, and the AI will hand you back code that looks correct, compiles cleanly, passes four of your five tests, and is subtly wrong.

The skill that catches that — that finds the one wrong line, in code you didn’t write, before it ships — is debugging discipline.

This chapter teaches that discipline on code you wrote, so that when Phase 2 asks you to apply it to code the AI wrote, your hands already know the moves. Debugging is not “trying things.” Debugging is not “putting print statements everywhere until something jumps.” Debugging is hypothesis-driven inquiry: you observe a symptom, you form a hypothesis about the cause, you make a small prediction that would distinguish the hypothesis from its rivals, you run an experiment, and the experiment either confirms or refutes the hypothesis. Then you do it again.

That cycle — observe, hypothesize, predict, test — is the scientific method, taught by a programmer to a programmer. The church has its own version of it, which Christians have been practicing for two thousand years. We call it the examen: a disciplined nightly review of the day in which the believer asks honestly, where did I go wrong, and why? The answer is not always pleasant. The discipline is in asking anyway.

Project 5 — Sabotage Recovery — gives you saboted versions of programs you’ve already written and asks you to find the planted bugs in a constrained number of runs. The constraint is the lesson. Without the constraint, students “debug” by mashing the run button and skimming output. With the constraint, students think.


5.1 — What a Bug Actually Is

A bug is a gap between what your code does and what your code is supposed to do. Three things have to be true for you to have a bug at all:

  1. There is an expected behavior — written down somewhere, even informally.
  2. There is an observed behavior — what the program actually did.
  3. The two disagree.

If you don’t know the expected behavior, you don’t have a bug. You have confusion. That’s a different problem and the fix is different — read the spec, ask the customer, write the spec yourself (Chapter 2). Half the “bugs” students chase are actually missing specifications.

If you don’t know the observed behavior, you don’t have a bug either. You have a suspicion. Run the program. Get an actual observation. Then come back.

A real bug, then, looks like: “When I enter 42 at the prompt, the program prints Total: 0. The spec says it should print Total: 42.” That’s two concrete things in disagreement. Now you can debug.

Coach’s Note — “It doesn’t work” is not a bug report. “When I do X, I expect Y, but I see Z” is. Train yourself to write the second sentence before you start poking the code. Half the time, just writing the sentence reveals the fix.


5.2 — Reading a Stack Trace

When Java throws an exception that isn’t caught, it prints a stack trace: a list of every method that was on the call stack at the moment the exception was thrown, deepest call first. Most students glance at the top, see “NullPointerException,” and start guessing. Don’t. The stack trace is telling you a story. Read it.

Here’s a real one:

Exception in thread "main" java.lang.NullPointerException:
        Cannot invoke "String.length()" because "name" is null
    at Greeter.greet(Greeter.java:14)
    at App.main(App.java:8)

There are four pieces of information here, and each one matters.

  1. The thread name"main". Most of your programs only have one thread, so this is usually main. If you ever see something else (like "pool-1-thread-3"), you’ve stumbled into concurrent code, which Coding 3 will treat properly.

  2. The exception classjava.lang.NullPointerException. This is the category of failure. NullPointerException means “you called a method on, or read a field of, a null reference.” ArithmeticException usually means division by zero. NumberFormatException means a string couldn’t be parsed as a number. Memorize the common ones — they are the JVM’s way of telling you which kind of mistake you made.

  3. The diagnostic messageCannot invoke "String.length()" because "name" is null. Java 17 includes “helpful NullPointerExceptions” by default, which name the actual variable that was null. Older Java versions just said NullPointerException with no message. If you ever see a bare exception with no message, you’re running pre-17, and you should suspect every reference on the offending line.

  4. The stack frames — each at ... line. Read them top-to-bottom. The top line, Greeter.greet(Greeter.java:14), is where the exception was thrown. The line below, App.main(App.java:8), is who called the method that threw. If there were more frames, each subsequent line would be the caller of the line above it.

Coach’s Note — The top frame is where the bug manifested. The bug lives somewhere — possibly the top frame, possibly higher in the stack where bad data was passed down. Start at the top. Walk down only as far as needed.

Here is the program that produced that trace:

// App.java
public class App {
    public static void main(String[] args) {
        Greeter g = new Greeter();
        String name = null;          // line 8 — passes null down
        System.out.println(g.greet(name));
    }
}

// Greeter.java
public class Greeter {
    public String greet(String name) {
        // line 14 — calls .length() on a null reference
        if (name.length() == 0) return "Hello, friend.";
        return "Hello, " + name + ".";
    }
}

The exception fires on line 14 of Greeter.java. But the cause — the place the fix belongs — is line 8 of App.java, where name is set to null. Walking the stack from top to bottom is how you find the cause.

The fix can go in either place, depending on the contract:

  • If greet is supposed to accept null and treat it as “no name,” the fix is in Greeter.
  • If greet’s contract requires a non-null string, the fix is in App — never pass null.

That’s where Chapter 2 (Contracts) starts to pay rent. The stack trace tells you where. The contract tells you whose fault.


5.3 — Common Java Exceptions and What They Usually Mean

ExceptionUsually means
NullPointerExceptionMethod call or field access on a null reference.
ArrayIndexOutOfBoundsExceptionArray access with a negative index or one past the end.
IndexOutOfBoundsException (subclasses include StringIndexOutOfBoundsException)Same idea, for List or String.
NumberFormatExceptionInteger.parseInt or Double.parseDouble on a non-numeric string.
ClassCastExceptionYou cast an object to a type it isn’t actually an instance of.
ArithmeticExceptionDivision by zero in integer arithmetic (floating-point gives you Infinity or NaN instead).
IllegalArgumentExceptionA method rejected an argument it considers invalid (often thrown by you, in your own code).
IllegalStateExceptionAn object isn’t in a valid state for the requested operation.
ConcurrentModificationExceptionYou modified a collection while iterating over it.
StackOverflowErrorInfinite recursion. Chapter 7 will name this on purpose.

The pattern: the exception type is a category, the message names the specific thing, and the stack trace tells you where in your code it happened. Three pieces. Always read all three.


5.4 — Hypothesis-Driven Debugging

The single most important habit in this chapter is this:

Form a hypothesis before changing a single line.

Most student debugging looks like: see the bug, immediately change something that might fix it, run again, see if the bug is gone. This is gambling. You will sometimes win. You will more often introduce a second bug while masking the first, and now you have two bugs and no idea which change matters.

Hypothesis-driven debugging looks like this:

  1. Observe the symptom precisely. What inputs? What output? What expected output? Write it down.
  2. Form a hypothesis. A specific claim about the cause. Not “something’s wrong with the loop.” Rather, “the loop runs one too few iterations because the bound is < when it should be <=.”
  3. Predict. If the hypothesis is true, what would I see if I made one small probe? Print a value? Read one specific variable in the debugger? Run with a different input?
  4. Test. Run the probe.
  5. Confirm or revise. Did the prediction match? If yes, you found the cause; now write the fix. If no, the hypothesis is wrong; form a new one. Do not change code yet.

Only after the hypothesis is confirmed do you write the fix. Then you write a test (Chapter 4) that locks the fix in place, so the same bug can’t sneak back.

Here is the discipline in code form. Say you have:

public static int sumToN(int n) {
    int total = 0;
    for (int i = 1; i < n; i++) {
        total += i;
    }
    return total;
}

// Caller:
System.out.println(sumToN(5));   // prints 10, expected 15

Symptom: sumToN(5) returns 10. Expected 15. Difference: 5.

Hypothesis: The loop stops one iteration early. It includes 1+2+3+4 = 10 but misses the +5. The bound should be <= not <.

Prediction: If I print i at the end of the loop, the last value printed will be 4, not 5.

Test: Add a System.out.println(i) inside the loop. Run.

Result: Last printed value is 4. Hypothesis confirmed.

Fix: Change < to <=.

Test the fix: Run sumToN(5). Result: 15. Run sumToN(0). Result: 0. Run sumToN(10). Result: 55. All correct.

Regression test:

@Test
void sumToFiveIsFifteen() {
    assertEquals(15, MathHelpers.sumToN(5));
}

That entire sequence took five sentences to describe and maybe two minutes to execute. Without the discipline, you might have stared at that loop for ten minutes, changed total = 0 to total = 1, gotten 11, panicked, changed it back, swapped += to =+ (the typo bug), and lost twenty minutes to the same problem. The discipline isn’t slow — it is dramatically faster once you internalize it.

Coach’s Note — Write down the hypothesis out loud, in a comment, in a sticky note, in a notebook. Externalize it. You cannot revise a thought you never made specific.


5.5 — Bisection: Binary Search Through Your Own Code

When the bug isn’t in the obvious place, the next move is bisection: cut the program in half and figure out which half contains the bug. Then cut that half in half. Repeat until the bug is cornered.

There are several flavors.

Bisecting by code

Suppose 200 lines of code produce a wrong final answer, and you don’t know where the wrongness enters. Pick a midpoint — line 100 — and print the relevant state there. Is the state already wrong? Bug is in the first 100 lines. Is it still right? Bug is in the last 100. Pick the midpoint of that half. After log₂(200) ≈ 8 probes, you’ve narrowed the bug to one line.

Bisecting by input

Some bugs only manifest on certain inputs. Take a known-failing input and shrink it. If processList([1,2,3,4,5,6,7,8,9,10]) fails, does processList([1,2,3,4,5]) fail? If yes, does [1,2,3] fail? If yes, does [1]? You are shrinking toward a minimal failing case, which is usually small enough to read by hand.

Bisecting by history (git bisect)

When you have version control and a bug that “used to not be there,” git bisect automates this: it checks out a midpoint commit, you tell it “good” or “bad,” and it narrows to the exact commit that introduced the regression. Coding 3 will teach this properly. For now, know that the technique exists, and recognize that it is the same idea — binary search over a space, with you supplying the yes/no signal.

Bisecting by component

If your program calls module A, then B, then C, and the final output is wrong, ask: is the output of A correct? If yes, suspect B. If still yes after B, suspect C. This is bisection over the call graph.

The unifying idea: the search space is too big to read, so cut it in half, decide which half contains the bug, repeat. Logarithmic discovery beats linear searching every time.


5.6 — println Debugging vs. Using the Debugger

The two main probing tools are print statements and the debugger. Both are legitimate. They are good at different things.

System.out.println("DEBUG enter sumToN(" + n + ")");
System.out.println("DEBUG i=" + i + " total=" + total);

Pros: trivial to write. Works anywhere — in production logs, in environments without a debugger, in code that’s already running. Persists in your output history so you can see a sequence of events.

Cons: clutters the code; easy to forget and ship; can’t inspect everything — only what you thought to print; you have to rerun the program for each new question.

Habit: prefix every debug print with DEBUG (or whatever uppercase tag) so you can grep them out before committing. Remove them when the bug is fixed.

Debugger

A real interactive debugger (the one built into IntelliJ, Eclipse, VS Code, BlueJ) lets you set a breakpoint on a line, run the program, and have execution pause at that line so you can inspect every variable, step through one line at a time, and step into or over method calls.

Pros: you see all variables, not just the ones you thought to print. Stepping reveals control-flow surprises (an if branch you didn’t expect to fire). Conditional breakpoints (break here when i == 47) target a specific iteration of a long loop.

Cons: requires an IDE configured for debugging — OnlineGDB has limited support; a proper local setup is a Coding 3 topic. There is also a real learning curve for the keybindings.

For Coding 2 in OnlineGDB, print debugging will be your primary tool, and that is fine. Real-world senior engineers reach for print and the debugger about equally often, and the choice is usually about which is faster for this bug right now. A bug deep in a long-running data pipeline that fails after ten minutes is a print-debugging bug. A bug in a tight loop where you don’t know which iteration goes wrong is a debugger bug.

Coach’s Note — Never apologize for println debugging. The senior engineers I respect use it daily. The point is the hypothesis, not the tool. A hypothesis tested with a println is a hypothesis tested.


5.7 — The Rubber Duck and Reading Aloud

When you are truly stuck — twenty minutes of hypotheses, none of them right — try the cheapest debugging tool ever invented: explain the code, line by line, out loud, to an inanimate object.

The original ritual is to keep a rubber duck on your desk and address the duck. The form does not matter; the verbal articulation does. Saying out loud “and then I take the average by dividing total by count…” has a strange way of making your brain notice that count was never incremented inside the loop. You knew it the whole time. Saying it forced your eyes to track every line.

A close cousin: read the code top to bottom out loud, slowly, with finger or cursor on each line. Don’t skim. Don’t trust what you “know” the code does. Read what is actually written.

These techniques sound silly. They work because they break the pattern of “the eye glides over the code that looks familiar.” Familiar code is exactly where the bug is hiding. You wrote it; you assumed it works. The bug exploits your assumption.

The theological cousin of the rubber duck is the daily examen. The Christian who reviews her day out loud, in writing, or to a confessor sees what self-deception had quietly hidden during the day itself. Programmers and Christians share the same problem here: we are not naturally good at seeing our own errors. Both crafts have invented the same kind of discipline to compensate.


5.8 — When to Give Up and Rewrite

There is a moment, after enough failed hypotheses, where the right move is not “keep debugging.” The right move is rewrite the affected section from scratch.

The signs:

  • You have been at this for over an hour and the bug count has gone up.
  • Every fix introduces a new failure somewhere else.
  • The code in question was written hastily, without tests, and you don’t fully understand what it does.
  • You’re starting to add // I don't know why this fixes it, but it does comments.

In any of these situations, throwing the section out and rewriting it from a clearer specification (Chapter 2!) is usually faster than continuing to debug. The original code’s flaws were structural, not local. Patching it makes it worse. Replacing it from scratch is the senior engineer’s escape valve.

This is not an excuse to skip debugging. The debugging discipline still matters — you need to understand what went wrong, even if the fix is “delete and rewrite,” because otherwise you’ll write the same bug into the replacement. The order is: form a hypothesis about what kind of bug this is (memory? logic? off-by-one? misunderstood API?), then decide whether the cleanest fix is a patch or a rewrite.

Coach’s Note — A rewrite is not a defeat. A rewrite is a senior-engineer move. The defeat is the third hour of patching the same hundred lines because you’ve sunk too much time to admit they were wrong from the start.


5.9 — Putting It Together: The Sabotage Recovery Protocol

Project 5 gives you sabotaged code. Three planted bugs, and you may run the program at most five times. Here is the protocol that wins:

Run 1: Establish baseline. Compile. Run with the provided “happy path” input. Observe the output. Compare to expected output. Identify the first symptom.

Between runs: Form hypotheses without running. Read the code top to bottom. Form a hypothesis for the observed symptom. Pick a small probe — a println of one specific value, or a re-run with one specific other input.

Run 2: Test hypothesis 1. Make only the probe change. Run. Observe. Confirm or refute. If confirmed, fix the bug. If refuted, the hypothesis was wrong; form a new one without running.

Run 3: Confirm fix + look for next bug. With the fix in place, run again. Has the symptom disappeared? Look for the next symptom. Form a hypothesis for it.

Run 4: Test/fix hypothesis 2.

Run 5: Confirm everything. All three bugs fixed. All three symptoms gone. Run with an additional input to test for regressions.

Five runs. Three bugs found and fixed. The constraint forces you to think between runs — which is the entire skill.

Without the constraint, the typical un-disciplined approach is: 30+ runs, mostly tiny tweaks, a fix that fixes one symptom while introducing a different bug elsewhere, and at the end nobody (including you) is sure what’s actually correct. The five-run constraint is artificial; the habit the constraint trains is the one you’ll use forever.


5.10 — Regression Tests: Locking the Fix In Place

Every bug you find and fix should produce a regression test — a JUnit test (Chapter 4) that fails before the fix and passes after. Without the test, the same bug can sneak back in a future refactor and you’ll never know.

@Test
void sumToFiveIncludesFive() {
    // Regression: previously sumToN(5) returned 10 because the
    // loop bound was < instead of <=. See git commit a3f9b.
    assertEquals(15, MathHelpers.sumToN(5));
}

The comment naming the prior bug is a gift to your future self. Three months from now, when someone “cleans up” your code and breaks the bound again, this test will fail loudly and the comment will explain why.

The discipline: every bug fix in this course should ship with a test. No exceptions. The grader for Project 5 specifically asks for the regression test alongside each fix.


5.11 — Common Bugs (Week 5 Edition)

Bug: I changed five things and now nothing works. What it means: You debugged by guessing, not by hypothesis. You have introduced new bugs while trying to fix the old one. Fix: Revert (Ctrl-Z back to the last known good state, or git stash if you’re using version control). Form one hypothesis. Change one thing. Re-test. Repeat.


Bug: I added a println and the bug disappeared. What it means: Either the println changed timing (rare in single-threaded Java code), or — much more likely — the println’s expression had a side effect that fixed it (auto-boxing, lazy initialization, accidental object construction). Or you accidentally fixed the bug while editing. Fix: Remove the println. Confirm the bug returns. If it doesn’t, the bug was actually fixed elsewhere — find that.


Bug: The same input produces different output on different runs. What it means: Something nondeterministic is in play. Common culprits in Coding 2: iteration order of a HashMap or HashSet (which is not guaranteed — Chapter 8), reading uninitialized fields, System.currentTimeMillis(), random numbers without a seed. Fix: Find the nondeterministic source. Seed it (new Random(42)), sort it (new TreeMap<> or sort the keys), or initialize the field.


Bug: Stack trace says “NullPointerException” but the line it points to has no obvious null. What it means: Java is dereferencing a sub-expression. roster.get(0).getName() can NPE because roster.get(0) returned null. Or map.get(key) returned null and you immediately called a method on it. Fix: Read the helpful NPE message (Java 17). If on older Java, decompose the line into separate statements so the NPE points at one variable.


Bug: ConcurrentModificationException when iterating. What it means: You modified a collection (added or removed) while iterating over it with a for-each or an explicit iterator. Fix: Collect changes into a separate list during iteration; apply them after. Or use iterator.remove() instead of collection.remove(...).


Bug: My fix works on the inputs I tested, but the grader’s tests fail. What it means: You tested the happy path. The grader tested edge cases. Empty input, null input, negative numbers, very large numbers, single-element inputs, duplicate elements. Fix: Before declaring a bug fixed, brainstorm at least three edge cases and run them.


Bug: The bug appears only when I run the full program but not when I test the method directly. What it means: State from earlier in the program is affecting the later behavior. Static fields, file handles left open, a Scanner partially consumed. Fix: Isolate. Write a minimal main that only exercises the suspect path. If the bug vanishes in isolation, the cause is the state. Hunt that.


5.12 — Reps

Open the exercises for the full set. The reps in this chapter are mostly find-the-bug drills — small broken programs with one or two planted bugs, and you practice the hypothesis-test-fix loop on each one. Do them all. They are the warm-up for Project 5.

A taste:

Rep 1. Read a stack trace cold and identify the buggy line.

Rep 2. Three programs that crash; for each, form a hypothesis before reading the code.

Rep 3. A program with an off-by-one that doesn’t crash but produces wrong output. Hunt with prints.

Full set in the exercises.


5.13 — This Week’s Project: Sabotage Recovery

You’re ready for Project 5: Sabotage Recovery, in Project 5.

You’ll receive a working program that has been deliberately broken with planted bugs. Your job: find and fix all the planted bugs while documenting your hypothesis, your probe, and your regression test for each one — in five runs or fewer for Normal tier.

The grader doesn’t grade only the fixes. The grader grades the discipline: did you write down your hypothesis before changing code? Did you probe with the smallest change that would distinguish hypothesis from rivals? Did you write a test that locks the fix in place?

Three tiers:

  • Normal — three planted bugs in a familiar program, five runs max, full documentation per bug.
  • Medium — three subtler bugs in a second program, same constraints.
  • Hard — five bugs in a third program with no oracle (no expected output supplied). You write your own oracle from the spec.

5.14 — Coach’s Final Word for Week 5

The discipline you built this week is the discipline you’ll use to vet AI-generated code in eight weeks. The AI will hand you a 30-line function and a confident explanation. Four of the lines will be correct. One will be wrong in a way that compiles and looks right. The skill that catches the wrong line is exactly the skill you just practiced — hypothesis, probe, confirm.

Without this discipline, the AI’s first answer is your last answer, and you ship the bug.

With this discipline, you ship the fix.

See you next week. Chapter 6 is files and data — and how the discipline of careful debugging extends naturally to the discipline of careful parsing.


Up next: Read the exercises and run every rep. Then open Project 5 and recover the saboted code. After that, Chapter 6 — Files, Data, and Persistence.