Debugging

This is a printer-friendly version. It omits exercises, optional topics (i.e., four-star topics), and other extra content such as learning outcomes.

What

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':

  • A mistake is the human (or AI) act that started it all.
    Example You misremembered that list indices start at 1.
  • A defect is the resulting error in the code. This is what most people mean by 'a bug'.
    Example A loop that starts counting from the wrong index.
  • An infection is the resulting error in the program state at run time. When the defective line executes, some variable now holds a wrong value.
  • A failure is the externally visible wrong behavior.
    Example A total shown to the user that is too small, or a crash.

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.

  • Mistake: the programmer assumed getItems() hands back a copy.
  • Defect: getItems() hands out the live internal list, which computeTotal() then empties — breaking both contracts at once.
  • Infection: after computeTotal() returns, the cart's own items list is empty.
  • Failure: the next attempt to display the cart shows nothing.

A debugger stopped at the failure would be pointing at the display code, which is correct.

Why debugging is hard

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:

  • The distance between defect and failure: the crash site is not the crime scene, so the instinct to study the code around the error message is often the least productive move available.
  • You cannot inspect everything — a running program holds an enormous amount of state, changing at every step, and choosing which small part to look at is most of the skill.
  • Your mental model of the code is exactly the thing that is wrong: had you understood it correctly you would not have written the defect, so re-reading with the same assumptions reproduces the same blind spot. Hence debugging must be driven by evidence from the running program, not by reasoning alone.

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.

How not to debug

Most unproductive debugging comes from having no method, rather than from using the wrong tool.

  • Bad Stare and hope — reading the code and waiting for the bug to reveal itself. This inspects the code but not the state, using the mental model that wrote the defect. Fine as a 30-second first try; a poor plan for the next two hours.
  • Bad Shotgun debugging — changing whatever looks suspicious and re-running to see whether it helped. Each run teaches you nothing: a change that does not fix the problem has neither confirmed nor eliminated any explanation, and unrelated edits accumulate.
  • Bad Debugging into existence — mutating the code until the symptom disappears. The symptom often vanishes because a second defect cancels the first, leaving two bugs and a harder problem later. The difference from shotgun debugging is the stopping rule: here you stop when the symptom goes away, having never identified a cause.
  • Bad Fixing the symptom instead of the cause. The failure goes away and the defect stays.
    Example Special-casing the input that fails, or wrapping the crash in an empty catch block.
  • Bad Keeping no record of what you tried. Without notes you will re-test explanations you already eliminated, lose your place when interrupted, and be unable to hand the problem over.

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.

How

Systematic debugging follows roughly this sequence:

  1. Track — state what the correct behavior is, and record the problem somewhere durable.
  2. Reproduce — make the failure happen on demand.
  3. Automate and simplify — turn the reproduction into a one-command test, and reduce it to the smallest case that still fails.
  4. Find origins — list the places where the state could first have gone wrong.
  5. Focus — pick the most likely origin, and state what it predicts.
  6. Isolate — run the check that decides, and conclude.
  7. Correct — fix the cause, confirm it, and guard against recurrence.

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.

  1. Observe steps 1 to 3 — collect what you know: the input, the expected result, the actual result, and any state already inspected. Facts only; no guesses yet.
  2. Hypothesize steps 4 and 5 — propose a specific, falsifiable explanation.
    Example "Something's wrong with the list" is not a hypothesis; "items is empty by the time computeTotal() returns" is.
  3. Predict step 5 — state what you would observe if the hypothesis were true, and if it were false.
  4. Experiment step 6 — run the smallest probe that distinguishes those outcomes: a breakpoint, an assertion, a targeted print.
  5. Conclude step 6 — reject the hypothesis, or record it as supported so far. The asymmetry matters: a result that matches your prediction does not prove your explanation is the only one that fits, whereas one that does not is decisive. You stop not when an observation matches, but when your explanation accounts for the whole failure — every symptom you saw, not only the one you probed.

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.

1. Track

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.

2. Reproduce

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.

3. Automate and simplify

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.

4. Find origins

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.

  • Reason backwards from the wrong value. Ask which statements could have produced it, then which produced their inputs. Following data and control dependencies backwards is called backward slicing — it narrows the candidates rather than pinpointing them, since a slice reliably contains every statement that could be responsible, usually along with some that could not.
  • Explain the code aloud, line by line. Rubber duck debugging — explaining it to an inanimate object — works for a real reason: articulating what each line does forces you to state assumptions you had taken for granted, and the wrong one tends to announce itself mid-sentence. A patient friend, a written explanation, or an AI chat window works too — provided you do the explaining and verify anything it suggests against the running program; the value is in articulating the code, not in the reply you get back.
  • Read the evidence you already have before generating candidates from the code alone. An exception message names the expression that failed, a stack trace names the calls that led there, and a diff names what changed recently.
5. Focus

Candidate origins are not equally likely, and the order you check them in decides how long the search takes.

  • Prefer recently changed code to long-stable code, your code to library code, and library code to the compiler or the operating system. This is a starting bias rather than a rule.
  • Turn the chosen origin into a prediction before you check it. State what you would observe if it is guilty and what you would observe if it is innocent; if you cannot say what the two outcomes would mean before you press run, you are not yet running an experiment.
  • Where two candidates both fit the evidence, look for the check that separates them, rather than one that merely agrees with your favorite.
6. Isolate

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.

  • Binary search along the execution is the highest-value technique here. Pick a point roughly halfway through the suspect region, pause, and ask one question: is the state already wrong? If yes, look earlier; if no, look later. Each check roughly halves the region still under suspicion.
  • Binary search over versions, when the code used to work. If it passed last week, the cause is in one of the commits since — bisect the history rather than the code. 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.
  • Swap a suspect component for one you trust. If the failure survives the swap, that component is very likely not responsible.
    Example Replace your comparator with a trivially correct one.
  • Change one thing at a time, or the outcome will not tell you which change produced it.
  • Record the conclusion, then start the next turn of the loop from the narrowed region — or, once the boundary is a single statement you can explain, move on to correcting the cause.

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.

  • Fail fast. Check preconditions and invariants on entry to a method, so an infection surfaces close to its origin instead of ten frames later. (related: defensive programming, assertions)
  • Keep scopes small. A variable visible across three lines has only three lines that could have changed it; a field visible across a class has the whole class.
  • Prefer immutability. A value that cannot change cannot be changed wrongly, which removes an entire category of "what modified this?" investigations — including the one in the cart example.
  • Develop incrementally, testing as you go. When only twenty lines are new, the defect is almost certainly in those twenty lines. This is a high-value habit often abandoned under time pressure.
  • Use the static checks you already have — compiler warnings, IDE inspections, linters, @Override, generics, final. A defect caught here costs no debugging at all.
  • Log at component boundaries, so a failure reported from the field arrives with its context attached.

Each of these shortens the distance between defect and failure, which is the root of the difficulty.

Fixing

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:

  • Look for the defect's relatives — the same mistake was probably made in the sibling method, the other branch, or the block copy-pasted from this one.
  • Verify against the reproduction, then run the full test suite. A fix that resolves your failure while breaking two other things is not a fix.
  • Add a regression test that fails before the fix and passes after it. If you automated the reproduction earlier, you already have it.
  • Remove your temporary probes — stray print statements, leftover breakpoints, commented-out experiments. Assertions and logging you added deliberately to stay are not temporary probes; keep those.
  • Commit the fix on its own, apart from unrelated cleanup, so that the history stays bisectable for the next bug.

Example The cart example, end to end:

  1. Track: computing a total must not change the cart's contents; filed as "cart empties itself after the total is shown".
  2. Reproduce: adding three items and then calling computeTotal() empties the cart every time.
  3. Automate and simplify: a test that adds items, calls computeTotal(), then asserts on getItems().size(). One item is enough to fail, so the test uses one.
  4. Find origins, Focus, Isolate: the only statement that could empty 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.
  5. Correct: both 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.

Tools

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).

  • Print statements are the cheapest to start with and the most expensive to iterate with. They need no setup, work in any environment, and survive across process and machine boundaries — but every new question costs an edit-build-run cycle, each edit is a chance to introduce a fresh defect, and leftovers reach production if you forget them.
  • Logging is the disciplined, permanent form of printing. Leveled and filterable, log statements can stay in the code — so they are still there when the failure happens on a user's machine at 3 a.m., where no debugger can reach.
  • Assertions are probes that check themselves. Rather than printing a value for you to examine, an assertion states what it should be and fails immediately when it is not, turning a silent infection into a loud, located failure. If you use Java's assert statement, enable assertions in your run configuration (-ea) or it will do nothing; test-framework assertions are separate and always run.
  • A debugger asks questions interactively, without changing the code at all.

As a rough guide:

  • reproducible and local → debugger
  • needs to survive into production → logging
  • want to catch the problem at its origin → assertions
Using a debugger

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.

  • A line breakpoint pauses when execution reaches a given line.
  • A conditional breakpoint pauses only when a condition holds. This makes debugging the 4137th iteration of a loop feasible at all, and it is the feature beginners most often do not know exists.
    Example Pausing only when i == 4137.
  • An exception breakpoint pauses at the moment an exception is thrown, before the stack unwinds and discards the state you need.
  • A field watchpoint pauses when a field's value changes rather than at a location — the right tool for "what is setting this to 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.

  • The call stack shows how execution reached this point, and selecting any frame reveals that method's variables. The cause is often several frames above where the program stopped.
  • The variables view shows the values currently in scope, and watches track a chosen expression as you step.
  • evaluate expression runs arbitrary code at the paused point, turning passive inspection into a live experiment: you can test a hypothesis without editing or restarting. One caution — evaluating really does run the code, so calling a method that mutates state, or setting a variable by hand, changes the program you are observing.

Example One session on the cart example, from the first breakpoint to the diagnosis:

  1. Set a line breakpoint on total += pending.remove(0).price();, then run the code that adds three items and calls computeTotal().
  2. At the first pause the variables view shows pending holding all three items, and nothing yet looks wrong.
  3. evaluate expression on 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.
  4. The call stack shows computeTotal() called from the display code, so the frame the failure will surface in is not the frame the defect is in.
  5. Resume, and watch 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:

  1. Set your first breakpoint before the suspected region rather than at the failure, so you can watch the state go wrong.
  2. Remember that a debugger reports only what the state is. The why still comes from the hypothesis loop.

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.

Reading stack traces

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:

  1. The exception type and message, which frequently name the problem outright.
  2. The topmost frame in your code — not the topmost frame overall, which is usually library or platform code doing exactly what it was asked.
  3. The chain of callers below it, which shows how execution arrived there.

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