Debugging: breakpoints, stepping, watching
Replacing print statements with a debugger, and the method that finds a bug faster than guessing.
Debugging is not a tool. It is a method, and the tool makes the method cheap.
Most people debug by guessing: read the code, form a theory, change something, run it, see. That works for small programs and stops working at about the point a program becomes interesting — because the number of possible theories grows faster than the time to test them.
A bug, before the method for finding it
Here is a program with one wrong character in it. Read it and decide what it prints before you scroll.
public class Report {
static int[] scores = { 70, 82, 91, 64, 88 };
static int average(int[] xs) {
int total = 0;
for (int i = 0; i <= xs.length; i++) {
total += xs[i];
}
return total / xs.length;
}
static void print() {
System.out.println("average: " + average(scores));
}
public static void main(String[] args) {
print();
}
}It prints nothing at all:
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 5
at Report.average(Report.java:7)
at Report.print(Report.java:13)
at Report.main(Report.java:17)<= instead of <. The array has five elements at positions 0 to 4, the loop asks for position 5, and there is nothing there. Hold on to this one — the rest of this lesson is about how you would have found it without being told.
The method
1. Reproduce it. If you cannot make it happen on demand, everything after this is guesswork. Find the smallest input that triggers it. Often this step alone identifies the bug, because shrinking the input means discovering which part matters.
2. Find where it goes wrong, not why. Resist explaining it. First establish where the program's state stops being what you expect — the line before which everything is fine and after which it is not. A bug is a point on a timeline, and you are looking for that point.
3. Narrow by halving. You have a start where things are correct and an end where they are not. Check the middle. That tells you which half to keep. Ten of those reduce a thousand lines to one — halving is logarithmic, so the search barely grows as the program does — and it works without understanding the code at all.
4. Now ask why. By the time you know the exact line and the exact values, the cause is usually obvious. Most of the difficulty was never the explanation.
5. Fix the cause. Not the symptom. If a value is null, do not add a null check — find out why it is null. The check may be right, but only after you know.
the whole file. You know two things and nothing else: it was right at the start and wrong at the end. That is enough to begin — you do not need a theory, and you do not need to understand the code.
check 500. Stop halfway and look at the state. It is still correct here, so the bug is not in the first half — five hundred lines eliminated by one observation.
check 750. Wrong here. So the bug is between 500 and 750, and the second half of what remained is gone too. Notice that a wrong answer narrows just as much as a right one.
check 625. Correct here, so the bug moved into the upper half again. Three observations have taken a thousand lines down to a hundred and twenty-five.
seven more. 125 → 63 → 32 → 16 → 8 → 4 → 2 → 1. Ten checks in total, and the tenth names the line. A file ten times longer would cost three more.
The method has a name, and the name adds something
What is written above is a practical form of scientific debugging, set out by Andreas Zeller in Why Programs Fail. His version is stricter in one way that is worth adopting, because it is the difference between investigating and poking:
- Observe a failure.
- Invent a hypothesis consistent with what you observed.
- Use it to make a prediction — something that must be true if the hypothesis holds.
- Test the prediction.
- Refine or discard, and repeat.
The step people skip is the third, and skipping it is what turns debugging into guessing. "I think the list is empty" is not a hypothesis you can be wrong about. "If the list is empty, then size() at line 40 is 0 and the loop body never runs" is — and now looking takes ten seconds and the answer means something either way.
Write it down. Zeller's recommendation is a debugging logbook, and it sounds like bureaucracy until the session that lasts two hours. By then you cannot remember which of six theories you have actually tested, and you start retesting the ones you have already ruled out. Three lines per hypothesis — what you thought, what you predicted, what happened — is enough.
Shrink the input, not just the code
Bisecting lines finds where. There is a second bisection that is often faster, and it is the one people do not think to do: bisect the input.
A 4,000-line CSV file makes the importer crash. Delete half; does it still crash? Keep the failing half and repeat. Ten or twelve rounds reduce it to a handful of rows — often to one row — and that row usually names the bug on sight: an empty field, a comma inside a quoted value, a date in a different format.
Zeller's delta debugging is this made automatic: a program that repeatedly removes parts of a failing input and keeps whatever still fails, until removing anything more makes the failure go away. What is left is a minimal reproducer.
You rarely need the automated version. You need the habit, and the habit pays twice: the minimal input is the fastest route to the cause, and it is the test case you keep afterwards — a failing case of one row rather than a fixture of four thousand.
What a debugger gives you
A debugger pauses a running program and lets you look inside it. Four capabilities, and they map onto the method:
Breakpoints — mark a line; execution stops when it gets there. This is how you implement "check the middle".
Stepping — once stopped, advance deliberately:
| Action | What it does | When |
|---|---|---|
| step over | run the next line, do not go inside a call | the usual one |
| step into | go inside the method being called | the call is the suspect |
| step out | finish this method, return to the caller | you went in by accident |
| continue | run until the next breakpoint | you are done here |
Variables — see everything in scope, right now, with its actual value. Not what you think it is. Not what it usually is. What it is.
The call stack — how you got here. Every method between main and this line, and you can click any of them to see its variables at the moment it called the next one. For "how did this method ever get called with that", this is the answer.
Why this beats print statements
Print statements work, and every engineer uses them. But:
- You must decide in advance what to print. A debugger shows you everything, including the variable you did not suspect.
- Each round trip is an edit, a rebuild, a re-run. A debugger is one pause.
- You must remember to remove them, and the one that reaches production prints a customer's data into a log.
Use prints for a loop running ten thousand times, where stopping is impractical, or for something that only happens in production. Use a debugger for everything else — which is most things.
Conditional breakpoints: the one that surprises people
A loop runs 10,000 times and fails on the one where id == 4471. Stopping every time is useless.
Every debugger lets you attach a condition to a breakpoint — id == 4471 — and it only stops when that is true. This turns an impossible session into a single pause, and it is the feature most people never discover.
Two relatives worth knowing: a breakpoint that triggers on an exception rather than a line (stop the moment any NullPointerException is constructed, wherever it is), and one that fires when a field changes (stop when this value becomes null, whoever did it).
Reading a stack trace
A stack trace is the one instrument you get for free, every time, without planning ahead — and most people skim it instead of reading it. It answers three questions, in three different places.
Here is a real one. A program reads two lines of a CSV file and prints the quantity from each:
3
Exception in thread "main" java.lang.NumberFormatException: For input string: " 7"
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.lang.Integer.parseInt(Integer.java:569)
at java.lang.Integer.parseInt(Integer.java:615)
at Orders.quantityOf(Orders.java:3)
at Orders.load(Orders.java:8)
at Orders.main(Orders.java:13)What went wrong is the first line, and the useful half is after the colon. For input string: " 7" — with quotes around it, because the quotes are what let you see the leading space. The file said bolt, 7, somebody put a space after the comma, and a space is not a digit. Without the quotes that message would be nearly useless.
Where it went wrong is not the first frame. The top three are inside the JDK, and Integer.parseInt is not broken. Read down to the first frame in code you own — Orders.quantityOf(Orders.java:3) — because that is the boundary where your program handed bad data to a correct library. Almost every trace you meet in a Spring service has twenty frames above that line.
How it got there is the rest, read bottom-up: main called load, which called quantityOf. When the question is "how was this method ever called with that", this is the answer, and it is the same information a debugger's call stack shows you — except this one arrived by itself.
And note the 3 on the first line. One row worked. The program was not broken in general; it was broken for one input, which is precisely why it reached production and why "reproduce it" is step one.
When you cannot attach a debugger
Production, usually. The method does not change; the instruments do.
- Logs — the print statements you wrote in advance, which is why what you log matters so much. A log line with the identifier and the values is worth ten that say "entering method".
- A stack trace — a snapshot of step 4, free, every time something throws. Read the first line for what, and the first frame in your package for where.
- A thread dump — what every thread is doing right now. For a program that is stuck rather than wrong, this is the tool.
Those come later in the course, in their own right. What carries over is the method: reproduce, locate, halve, then explain.
Try it yourself
Debug a program you did not write
Take any small program with a bug — a loop that is off by one, an average that comes out wrong. Then, deliberately:
- Predict, in writing, what the variable will be at the failing line.
- Put a breakpoint there and look.
- If you were right, you looked in the wrong place — move earlier and repeat.
- When the value first surprises you, you have found the line. Only then work out why.
Why step 1 matters
Writing the prediction down first converts debugging from watching into testing. If you predicted correctly, you learned nothing at that line — and that is useful information, because it means the divergence is earlier.
It also breaks the most common failure mode: staring at a debugger, seeing values, and having no idea whether they are right. Values only mean something against an expectation.
Misconceptions
- "Good engineers do not need a debugger." They reach for one sooner, because they know how fast it settles a question.
- "The debugger will tell me the bug." It shows state. Finding the cause is still yours; the tool removes the guessing about where.
- "It is only for hard bugs." It is fastest for the easy ones, which is most of them.