Software Craft
Debugging Software Systematically
Updated 2026-07-13 · 8 min read
The difference between someone who is good at debugging and someone who dreads it is not intelligence. It is method. Bad debugging is changing random things and hoping. Good debugging is a loop: form a theory about what is wrong, run an experiment that would prove it, and narrow the search each time.
A bug means your mental model of the program is wrong somewhere. The whole job is finding the exact point where reality diverges from what you believed. Do that patiently and almost any bug falls.
Reproduce it first
You cannot fix what you cannot see happen on demand. Before anything else, get the bug to occur reliably, ideally with the smallest possible set of steps.
A solid reproduction is worth more than any clever guess because it gives you a fast feedback loop. If you have to click through five screens and wait two minutes to see the bug, every experiment costs two minutes. Get it down to one command or one test.
- Write down the exact steps, inputs, and environment that trigger it.
- Strip away everything that is not required. Does it still happen with a smaller input? Without that plugin? On a fresh database?
- If it is intermittent, look for the hidden variable: timing, ordering, uninitialized memory, a shared cache, a race between two requests. "Random" bugs almost always have a cause you have not spotted yet.
The best possible reproduction is a failing automated test. If you can capture the bug as a test, you get a precise trigger now and protection against regressions forever.
Actually read the error
This sounds obvious and yet it is the step most often skipped. When something breaks, people glance at the first red line, feel a jolt of panic, and start guessing. The error message and stack trace usually contain most of the answer.
- Read the whole message slowly. It names the error type, often the exact value, and where it happened.
- Read the stack trace from the top, but find the topmost frame in your code — that is usually where to start looking, even if the crash surfaced deeper in a library.
- Watch for the classics: a
NullPointerException/ "undefined is not a function" means something you expected to exist did not. An off-by-one means a boundary is wrong. A type error means data is not the shape you assumed.
If the message is genuinely cryptic, paste the exact text into a search engine. You are almost never the first person to hit it. And if there is no error at all — just wrong output — your first job is to add one, by asserting what you expected and letting it fail loudly.
Isolate the problem
Once it reproduces, shrink the search space. The fastest way to find a bug in a thousand lines is to prove it is not in nine hundred of them.
Binary search the code. Pick a point roughly halfway through the suspect path and check the state there. Is the data correct at this point? If yes, the bug is downstream; if no, it is upstream. Repeat, halving each time. A handful of these checks narrows a huge codebase to a few lines.
Binary search the history. If the code used to work, the bug lives in some commit since. git bisect automates finding exactly which one:
git bisect start
git bisect bad # current version is broken
git bisect good v1.4.0 # this old version worked
# git checks out the midpoint; you test and mark it:
git bisect good # or: git bisect bad
# repeat until git names the exact commit, then:
git bisect resetIn a project with thousands of commits, bisect finds the culprit in around a dozen steps. Knowing the exact commit that introduced a bug often tells you the cause instantly. (See Git & Version Control for more.)
Form a hypothesis, then test it
This is the heart of the method and the part beginners skip. Before you change anything, say out loud — or in a comment — what you think is wrong and how you would know.
"I think total is negative because the discount is applied twice. If so, logging it right after the discount step should show a value below zero."
Then run that one experiment. Notice what this does: it commits you to a falsifiable prediction. If the log shows a positive number, your theory is wrong and you have learned something real, instead of shotgunning changes and losing track of what you have tried.
Change one thing at a time. If you change five things and the bug goes away, you do not know which one fixed it — and you may have introduced two new bugs that happen to cancel out. One variable per experiment. Keep a note of what you have ruled out so you do not circle back to it.
Be ruthlessly honest with yourself about assumptions. The bug is almost always hiding in the thing you were sure was correct, which is exactly why you never checked it. When you are stuck, the next move is to verify an assumption you have been taking for granted.
Use the right tools
You can debug with print statements alone, and sometimes that is fastest. But a few tools pay for the time it takes to learn them.
A debugger. Setting a breakpoint and stepping through, inspecting every variable at each line, beats sprinkling prints — you see the whole state, not just what you thought to print. Learn to set breakpoints, step over and into calls, and watch expressions.
# A breakpoint you can drop anywhere, no setup, in modern Python:
def apply_discount(total, pct):
breakpoint() # execution pauses here; inspect total and pct
return total - (total * pct)Logging. For problems you cannot pause — production, timing-sensitive races — structured logs are your eyes. Log the inputs and outputs of the suspect region with enough context to reconstruct what happened.
Version control. git diff, git log, and git blame answer "what changed and when", which is often the whole question.
A rubber duck. Explaining the problem step by step to another person — or a rubber duck on your desk — forces you to slow down and articulate assumptions. You will frequently spot the bug mid-sentence, before the duck has to respond.
Know when to step back
Debugging has diminishing returns when you are tired. Staring at the same twenty lines for the fortieth time, you stop reading them and start hallucinating what you expect to see.
When you have been stuck for a while, take a real break. Walk away. An astonishing number of bugs solve themselves in the shower because your background mind kept working once you stopped forcing it. Sleep is a legitimate debugging tool.
When you come back, or before you leave, question the frame. Have you actually reproduced it, or are you debugging a symptom? Is the bug even where you think it is? Sometimes the fastest fix is to explain the whole thing to a colleague from the top — the act of retelling exposes the wrong assumption you have been protecting all along.
Common mistakes
- Changing code before understanding the bug. Guess-and-check occasionally works and always leaves you not knowing why.
- Not reproducing first. Fixing a bug you cannot trigger means you cannot confirm the fix.
- Ignoring the error message. The answer is often right there in the text you skipped.
- Changing several things at once. Now you cannot attribute the result to any single change.
- Trusting your assumptions. The bug lives in the code you were certain about.
- Fixing the symptom, not the cause. Clamping a negative total to zero hides the double-discount bug instead of fixing it.
- Not writing a test after. A bug fixed without a test is a bug that will come back.
- Grinding while exhausted. Past a point you are adding bugs, not removing them.
Checklist
- The bug reproduces reliably, ideally from a single command or test.
- You read the full error and stack trace, not just the first line.
- You narrowed the location by bisecting the code and/or the history.
- You wrote down a specific hypothesis before changing anything.
- You changed one variable at a time and noted the result.
- You found and fixed the root cause, not just the visible symptom.
- A test now captures the bug so it cannot silently return.
- If stuck, you took a break or explained it to someone.
Keep learning
The classic book is David Agans' Debugging: The 9 Indispensable Rules, which distills this whole discipline into rules like "quit thinking and look" and "make it fail". It is short and genuinely changes how you work.
Beyond reading, the fastest way to improve is deliberate practice with your tools: spend an afternoon learning your language's debugger properly, and set up git bisect on a real bug once so it is muscle memory when you need it. Pair this with How to Structure a Software Project — clean boundaries make bugs far easier to isolate — and Linux for Developers for the command-line tools that speed up investigation.
