The debugging topics of this textbook draw substantially on The Debugging Book by Andreas Zeller et al., in particular its terminology of defects, infections, and failures, and its treatment of debugging as an application of the scientific method. Further material was adapted from the debugging readings of MIT 6.102, UC Berkeley CS61B, Stanford CS107, UW CSE 332, and CMU 15-213.
Debugging is the process of finding the cause of a known problem in a program, and fixing it. It starts after you know something is wrong — whether a test, a user, or monitoring exposed it; finding that the problem exists is a separate activity. The hard part is usually the diagnosis rather than the correction: once you understand why a program misbehaves, the edit itself is often a single character — though choosing which edit is a decision in its own right.
To debug well, distinguish four things that beginners tend to lump together as 'the bug':
1.These four form a chain, and each link can be far from the next:
mistake → defect → infection → infection → ... → failure
(code) (state) (state) (behavior)
Debugging is therefore a search, not a lookup: you observe the failure but must fix the defect. The infection spreads as the wrong value is passed on, stored in a field, or used to compute another wrong value, so where the program crashed is usually not where the mistake was made. The chain also explains why bugs hide: a defect infects the state only when that line executes, and an infection becomes a failure only if it propagates out to something observable. A defect can sit in daily-executed code for months unnoticed.
The chain assumes the fault lies in code — the common case, but not the only one. A failure can equally originate in configuration, in data left by an earlier version, in a dependency, in the deployment, or in a requirement that was wrong to begin with. Sometimes the program is right while the test is wrong — the test may hold its own defect, or the expectation it encodes may never have been correct. Only an error in the code is a defect; where the fault lies elsewhere, call it the cause. Either way the search is the same: locate whatever has to change.
Example A running example, reused throughout the related debugging topics of this textbook. A shopping cart prints the correct total, but then appears empty.
class Cart {
private final List<Item> items = new ArrayList<>();
void add(Item item) {
items.add(item);
}
List<Item> getItems() {
return items;
}
int computeTotal() {
int total = 0;
List<Item> pending = getItems();
while (!pending.isEmpty()) {
total += pending.remove(0).price();
}
return total;
}
}
Take the cart's intended contracts to be these: computing a total must not change the cart, and getItems() lets callers read the items without owning the list.
getItems() hands back a copy.getItems() hands out the live internal list, which computeTotal() then empties — breaking both contracts at once.computeTotal() returns, the cart's own items list is empty.A debugger stopped at the failure would be pointing at the display code, which is correct.
Debugging consumes a large share of real development effort, and a single stubborn defect routinely costs more than writing the code it hides in. Beginners tend to read time spent debugging as evidence that they are bad at programming. It is not; debugging is a distinct and learnable engineering skill.
Three things make it hard:
Debugging time is therefore not proportional to the size of the fix. A one-character correction can cost an afternoon. The cost lives in the search, and every debugging technique aims at making that search cheaper.
Most unproductive debugging comes from having no method, rather than from using the wrong tool.
catch block.Adding temporary print statements is not necessarily bad; doing so without a hypothesis is — that is shotgun debugging in another form. A few prints chosen to answer a specific question are legitimate, and in production, embedded, or concurrent settings they are sometimes the only tool available.
What these have in common is that they produce activity without producing information. A productive debugging step is one that rules something out.
Systematic debugging follows roughly this sequence:
The initials spell TRAFFIC, a widely used mnemonic for the debugging process due to Andreas Zeller.
Treat this as a map rather than a mandatory order. Steps 4 to 6 form a loop that turns several times before it lands on the cause, and you can reorder the earlier steps freely: simplifying often finds the origin for free, and a failed isolation sends you back for a better reproduction.
The most common mistake is jumping straight to step 7. Starting at 'correct' and working backwards is how shotgun debugging happens. This unit covers steps 1 to 6; step 7 is covered separately.
Steps 1 to 6 of TRAFFIC are the scientific method applied to a program: you have an unexplained phenomenon, you propose an explanation, and you test it. Applying it deliberately is what separates systematic debugging from guesswork; every technique that follows exists to make one turn of this loop cheaper.
items is empty by the time computeTotal() returns" is.Keep a debugging log. One line per hypothesis, prediction, observation, and conclusion sounds bureaucratic, but it stops you re-testing rejected explanations, survives interruptions, and is what you hand over when the bug becomes someone else's. Start one as soon as the investigation will outlast a few hypotheses, or as soon as you catch yourself repeating a probe.
Example A debugging log for the cart example, in which computeTotal() empties the very list that getItems() handed it:
| # | Hypothesis | Prediction | Observation | Conclusion |
|---|---|---|---|---|
| 1 | add() never stored the items | items.size() == 0 right after adding | size() == 3 | Rejected |
| 2 | Something empties the list during computeTotal() | size() drops from 3 to 0 across the call | 3 before, 0 after | Supported — narrowed to that method |
| 3 | pending and items are the same object | the two identities match when stepping into the loop | same object | Supported — and it accounts for the whole failure: correct total, then an empty cart |
Rejecting hypothesis 1 is what suggested hypothesis 2.
Know when to stop for the day: Debugging is unusually sensitive to fatigue, because the whole activity consists of holding a model of the program in your head.
Debugging starts with being able to state what the correct behavior is, and why. Without that you have nothing to compare the program against, and you risk searching code that was right all along. State the expectation in the form the test will eventually take: for this input, that exact result.
Record the problem somewhere durable, so it survives an interruption — an issue tracker entry for anything beyond a few minutes of work, or a note beside you for the rest. A useful record holds the expected behavior, the actual behavior, the conditions under which you saw it, the steps to reproduce it, and the smallest failing case you have. This is not the same thing as a debugging log: the record holds the problem, while the log holds the investigation, one line per hypothesis and what it settled.
Sometimes the fault is in the test, not in the code under investigation: the test may hold its own defect, or its expectation may be wrong. Worth considering early, because it is easy to lose hours to a test that was wrong all along.
A reliable reproduction is the most valuable thing you can have, because it makes every experiment cheap and is the surest way to confirm afterwards that the fix worked.
Reproducing means recreating everything the failure depends on, usually more than the input alone: the input data, the program version, the environment and configuration operating system, locale, file paths, settings, the sequence of actions, and the starting state, such as leftovers from a previous run.
Build the reproduction deliberately rather than waiting for the failure to recur. Pin every source of nondeterminism you control — the random seed, the clock and time zone, iteration order, and the number of threads. Script the sequence of actions instead of performing it by hand, so it is identical every time. Reset to a known starting state before each attempt, so a leftover from the previous run cannot decide the outcome. And record the environment values from the run that failed, so you can restore them rather than guess at them.
When you cannot reproduce a failure you can still investigate it, but the work changes character. Instead of running experiments you mine the evidence left behind: stack traces, logs, crash dumps a snapshot of the process's state at the moment it died, thread dumps what every thread was doing or waiting for, and the differences between runs that failed and runs that did not. The immediate goal becomes making the failure more observable or more frequent — logging around the suspected area, tightening assertions, or finding the extra ingredient that decides between the two outcomes. Reproducibility is not all-or-nothing: moving a bug from 'once a week' to 'one run in five' is real progress. Without a reproduction you also confirm the fix differently: test the mechanism you believe was wrong, then watch for recurrence over a period long enough to mean something.
Automate the reproduction as a test case as early as you can. Turning "launch the app and perform these six steps" into a one-second command is what makes the hypothesis loop affordable, and it becomes the regression test once you have a fix. You will run it dozens of times before you are done.
The smaller the failing case, the smaller the search space — every element you can remove while the failure persists eliminates a whole category of possible causes.
Try halving the input first: cut it in half, test each half, keep whichever still fails, repeat. When it works it is very cheap, and needs no insight into the code.
But halving frequently does not work. Sometimes neither half fails, because the failure needs two elements the cut separated. Sometimes both fail for an unrelated reason, because half an input is not valid input — half a Java file does not compile, half a config file lacks its required header. Then remove smaller pieces one at a time, preserving whatever structure the format demands.
Simplify the code path too, not just the data: strip away unrelated features, configuration, and calls until only the failing core remains. A failure that survives is far easier to reason about; one that does not has told you something about what it depends on.
Example A 500-line configuration file makes the app crash at startup. Halving gets nowhere: neither half crashes, because the failure needs one setting from each. Removing settings one at a time from the full file isolates the pair — a theme entry and a locale entry, each harmless alone. Every trial file must keep the required header, or the app rejects it for an unrelated reason and the experiment tells you nothing.
A good bug report is a reproduction that someone else can run. The work of reproducing and simplifying is the content of the report — which is why producing a minimal example so often solves the problem before it is filed. When you cannot reproduce a failure, report the evidence you do have logs, stack traces, the conditions under which it appeared rather than nothing.
An origin is a place where the state could first have gone wrong: before it the state is correct, after it the state is infected, and the cause sits at that boundary. This step aims at a list of candidate origins rather than a single answer — a search that begins with one suspect usually ends by wrongly confirming that suspect.
Candidate origins are not equally likely, and the order you check them in decides how long the search takes.
Isolating means running one check, discarding the part of the search space it rules out, and repeating until the boundary narrows to a single statement.
git bisect automates this, and works best with small, self-contained commits; one you cannot build or test must be skipped, leaving several candidates rather than one. Martin Fowler calles this Diff Debugging.Example Binary search along the execution, on a run too long to watch: a 10,000-row import produces the right running total at the start and the wrong one at the end, and nothing in between is visible. Pause at row 5,000 and ask one question — is the total already wrong? If it is, the cause lies in the first half, so pause next at row 2,500; if it is not, pause at row 7,500. Fourteen such checks reduce 10,000 rows to one, and none of them requires understanding the code — only the ability to say whether the state is already wrong.
The cheapest bug to debug is the one that announces itself, and most of what makes code debuggable is decided long before the bug exists.
@Override, generics, final. A defect caught here costs no debugging at all.Each of these shortens the distance between defect and failure, which is the root of the difficulty.
Do not settle on a fix until you can explain the whole failure. Temporary changes made as experiments are fine, but a change you intend to keep needs a causal account that explains every observed behavior. The strongest check is to predict, before making the change, exactly what will be different afterwards, then verify that prediction. A fix that works for reasons you cannot state will come back.
Fix the cause, not the infection and not the failure. Special-casing the failing input or clamping a bad value removes the symptom and leaves the cause. Also consider whether you have a coding error or a design error: a coding error is a defect — the code does not do what you intended — whereas a design error means the intention itself was wrong. The second cannot be repaired at the site of the failure — patching there breeds special cases, and the real remedy is a design change. These are the two common cases, not the only ones: the cause can equally sit in configuration, data, a dependency, or the requirement, and then the correction belongs there.
Which change counts as 'the fix' is sometimes a genuine choice. Making that choice consciously, rather than patching whichever line you happened to be looking at, is part of fixing properly.
Example In the cart example you could make getItems() return a copy, or make computeTotal() iterate without mutating. Both remove the failure; they differ in which contract you treat as authoritative.
Once you have a candidate fix, finish the job:
Example The cart example, end to end:
computeTotal() empties the cart every time.computeTotal(), then asserts on getItems().size(). One item is enough to fail, so the test uses one.items is the loop in computeTotal(), and size() dropping from 3 to 0 across the call — with pending and items confirmed to be the same object — settles it.getItems() returning a copy and computeTotal() iterating without mutating would remove the failure, so the real question is which contract to treat as authoritative. getItems() is an accessor, and an accessor that hands back live internal state makes every caller a potential mutator — so that is the one to change, and it returns List.copyOf(items). Check the relatives while you are there: any other getter on the class that returns an internal collection has the same problem. The test now passes, the rest of the suite still passes, that test stays behind as the regression test, and the breakpoints used while isolating come out.Every way of looking inside a running program is a probe — a means of answering one specific question about its state. The useful question is never "print statements or debugger?" but "what is the cheapest probe that answers this question?" Some probes come out once the bug is found (e.g., a breakpoint or a temporary print statement); others are meant to stay (e.g., a permanent log statement added at a component boundary).
assert statement, enable assertions in your run configuration (-ea) or it will do nothing; test-framework assertions are separate and always run.As a rough guide:
A debugger lets you pause a running program, then inspect and control it from the inside, without modifying its code. That last part is what makes it different in kind from printing: asking one more question costs seconds rather than another edit-build-run cycle.
Breakpoints determine where the program pauses.
i == 4137.null?"Disable breakpoints rather than deleting them, so that a debugging session can be paused and resumed.
Stepping commands determine how execution advances. step over runs the next line, including any call it makes, as one step. step into enters the method being called. step out finishes the current method and pauses at its caller. run to cursor continues to a chosen line.
The inspection views tell you what state the program is in.
Example One session on the cart example, from the first breakpoint to the diagnosis:
total += pending.remove(0).price();, then run the code that adds three items and calls computeTotal().pending holding all three items, and nothing yet looks wrong.pending == items answers true. That single evaluation is the diagnosis — the list being emptied is the cart's own — and it cost no edit, no rebuild, and no re-run.computeTotal() called from the display code, so the frame the failure will surface in is not the frame the defect is in.items.size() fall in the variables view as the loop runs.A field watchpoint on items would not have helped here: items is assigned once, where it is declared, so the watchpoint fires at construction and never for remove(0). A watchpoint catches a field being reassigned, not the object it already points at being modified.
Two habits are worth forming:
AI assistants are useful for some parts of debugging and unreliable for others. They are good at explaining unfamiliar error messages, proposing candidate hypotheses, and serving as an always-available rubber duck. They are unreliable at diagnosing a defect in code they cannot run, and will produce confident, fluent, incorrect explanations. A systematic method is what makes them safe: treat any suggestion as a hypothesis, insist it be falsifiable, and verify it against the running program yourself.
A stack trace is a precise report of where a program failed and the call path that led there — yet beginners routinely scroll past it. Note its limit: the call path is exact, but how the program came to be in that state is not in the trace.
Read it in this order:
The top of the trace is where the failure surfaced, but the cause is often further down, in whichever frame passed the bad value along. In wrapped exceptions, read the Caused by: chain from the bottom up.
| Exception | Usually means |
|---|---|
NullPointerException | Something never initialized, or a method returning null unnoticed |
IndexOutOfBoundsException | An off-by-one, or an index computed from stale size information |
ClassCastException | An object that is not the type assumed, often after an unchecked cast |
ConcurrentModificationException | A collection modified while being iterated over, usually in a single thread Example removing from a list inside a for-each loop over that list |
StackOverflowError | Recursion with a missing or unreachable base case |
NumberFormatException | Unvalidated input being parsed as a number |