Lexicon
⌘K
Explore
ExploreGuidesNotes
WriteMy LibraryBooks
Sign in
Browse

Learning

  • Building a Sustainable Learning System

Networking

  • OSI Model
  • TCP vs UDP
  • TCP/IP Model

Programming

  • Start Here — How to Solve a Problem
  • From if/else to DSA — The Whole Path
  • Problem Solving
  • DSA Practice — Learn by Solving
  • C Programming
  • Python

Software Craft

  • How to Structure a Software Project
  • Debugging Software Systematically
  • Designing Clean and Maintainable User Interfaces

Tools & Workflow

  • A Practical Introduction to Git and Version Control
  • Introduction to Linux for Developers

Web & APIs

  • Understanding REST APIs

Writing

  • Writing Better Technical Documentation
New noteSuggest a guide

Lexicon

a personal knowledge base

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 reset

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

← PreviousHow to Structure a Software ProjectNext →Designing Clean and Maintainable User Interfaces
Reading progress0%

Curated guide

Part of the Lexicon knowledge base — hand-written and kept up to date.

Browse all guides →