Debugging Methodically With Claude Code
Point Claude Code at a bug report and, left to its own defaults, it will often start editing. It adds a null check, wraps something in a try/catch, tweaks a condition, reruns, and if the error changes it declares progress. Sometimes this works. More often it produces a diff that makes the symptom disappear without anyone — human or model — understanding why the bug happened. That's not a fix. That's a coincidence you shipped.
The alternative is a method that actually finds root causes: reproduce first, form one falsifiable hypothesis, instrument to test it, confirm the cause with evidence before touching code, then lock it in with a regression test. It's the same discipline a careful engineer uses at a debugger — the trick is getting the model to follow it instead of reaching for the edit tool. The examples are TypeScript because it's compact; the method is language-agnostic.
Why guess-and-check feels fast but costs you
Editing before understanding is seductive because the feedback loop is short. Change something, rerun, watch the output move. It feels like debugging. But every unguided edit is a shot in the dark, and three things go wrong.
First, you can mask the symptom while leaving the cause live. A ?? [] that stops a crash also hides the empty result that was the actual bug — now it fails silently instead of loudly. Second, you accumulate scar tissue: defensive checks and retries added "just in case," none of which you can later remove because nobody knows which one is load-bearing. Third, and worst, you never learn the failure mode, so the same class of bug returns wearing a different hat next week.
Claude Code is especially prone to this because it can act so cheaply. A human hesitates before editing five files; the model doesn't feel the cost, so without direction it treats editing as thinking. Your job is to make it earn the edit.
The loop: reproduce, hypothesize, instrument, confirm, then fix
Give Claude Code this explicit order of operations and hold it to each gate before the next.
1. Reproduce it, deterministically. You cannot fix what you can't trigger on demand. The first deliverable is not a theory — it's a command, a test, or a curl that makes the bug happen every time. "It's intermittent" usually means you haven't yet found the input or timing that controls it; that hunt is the debugging. A reliable repro is also your later proof of a fix.
2. State one falsifiable hypothesis. Not a list of five suspects — one, phrased so it can be proven wrong. "The cache isn't keyed by user, so two users in one process get the same settings" is falsifiable: if a second user gets different settings, the hypothesis is dead and you move on. "Something's wrong with the caching" is not a hypothesis; it's a shrug. Forcing a single sharp claim is what stops the shotgun approach.
3. Instrument to test it. Add logging, print the actual values, drop a breakpoint, inspect the real SQL. You are gathering evidence for or against the hypothesis — not editing behavior. This step changes nothing about how the program runs; it only makes the truth visible.
4. Confirm the root cause with evidence — before changing code. The instrumentation either shows the smoking gun or it doesn't. If the logs prove the mechanism, you've earned the fix. If they don't, you have a new fact and a new hypothesis — loop back to step 2. Crucially, no production code changes until this gate passes.
5. Fix, then add a regression test. Only now do you edit. And the fix isn't done until a test that fails on the old code and passes on the new one exists, so the bug can't silently return.
A prompt that installs this in a session:
Don't edit any code yet. First give me a command that reproduces this bug every time. Then state one falsifiable hypothesis about the cause. Then add logging to test it and show me the output. Only once the logs confirm the mechanism do you propose a fix — and include a test that fails before the fix and passes after.
A worked example: the settings that belonged to someone else
Bug report: "Users occasionally see another account's notification settings." Alarming, vague, and "occasional" — exactly the report that invites flailing.
Here's the code:
let cached: Settings | null = null; // module scope — lives for the whole process
export async function getSettings(userId: string): Promise<Settings> {
if (cached) return cached;
cached = await db.settings.findByUser(userId);
return cached;
}
The guess-and-check path. Claude sees "occasional" and "settings," and starts editing: maybe add a TTL to the cache; maybe the DB query needs a stronger filter; maybe wrap it in a lock. Each edit changes the symptom's frequency, none explains it, and the diff grows.
The methodical path. Reproduce first — the word "occasional" is the clue that it's shared state across requests, so trigger it deterministically:
const a = await getSettings("user_A"); // cold process
const b = await getSettings("user_B");
console.log(a.userId === b.userId); // true — B got A's settings
That reproduces every time on a fresh process. Now one falsifiable hypothesis: the module-level cached singleton ignores userId, so the first caller after startup poisons the cache for everyone until restart. It's falsifiable — if B got its own row, the theory is wrong.
Instrument to test it, without changing behavior:
export async function getSettings(userId: string): Promise<Settings> {
if (cached) {
console.log(`[settings] cache HIT for req user=${userId} -> row=${cached.userId}`);
return cached;
}
cached = await db.settings.findByUser(userId);
console.log(`[settings] cache MISS user=${userId} -> row=${cached.userId}`);
return cached;
}
The log is the evidence:
[settings] cache MISS user=user_A -> row=user_A
[settings] cache HIT for req user=user_B -> row=user_A ← confirmed
req user=user_B served row=user_A. The root cause is proven, not guessed: a process-wide singleton with no per-user key. Now the fix is obvious and correct — key the cache by user:
const cache = new Map<string, Settings>();
export async function getSettings(userId: string): Promise<Settings> {
const hit = cache.get(userId);
if (hit) return hit;
const row = await db.settings.findByUser(userId);
cache.set(userId, row);
return row;
}
A TTL would have made the leak rarer and the report harder to reproduce — actively worse. Only the evidence pointed at the real mechanism.
Lock it in with a regression test
The fix isn't finished until a test proves it and guards it. Write the test that fails against the old singleton and passes against the keyed cache:
test("settings are isolated per user", async () => {
const a = await getSettings("user_A");
const b = await getSettings("user_B");
expect(a.userId).toBe("user_A");
expect(b.userId).toBe("user_B"); // fails on the old singleton, passes now
});
Run it against the pre-fix code and watch it go red — that's how you know it exercises the bug rather than merely passing. A regression test that never saw the broken code is faith, not evidence. This one turns a one-time fix into a permanent fence.
Make the discipline the default
Re-typing "don't edit yet, reproduce first" every session is a tax, and under time pressure you'll skip it — exactly when you most need it. Encode the loop instead. A skill is a procedure that loads into your current conversation when the task matches; it's the right home for a debugging standard, because it applies inline, in the same thread, the moment a bug report shows up. A debug-methodically skill whose body is the five gates above means every "why is this failing?" request follows reproduce → hypothesize → instrument → confirm → fix → test, without you restating it.
For the confirm gate specifically, a subagent — a separate Claude with its own context window — earns its keep as an independent check on your proposed root cause, since it isn't anchored to the theory you already talked yourself into. If you'd rather adapt a working setup than build one, the claude-code-starter repo ships a ship skill, an adversarial-reviewer subagent, and a bug-hunt workflow — the same "verify with evidence before you trust it" shape, applied to shipping code generally.
Conclusion
The reason Claude Code guess-and-checks is that "fix this bug" is an underspecified request, and the fastest thing that satisfies it is an edit that moves the symptom. Redefine done. Make it reproduce the bug on demand, commit to one falsifiable hypothesis, instrument to gather evidence, and confirm the mechanism with an actual log line or failing assertion before any production code changes — then fence it with a regression test that fails on the old code. A masked symptom returns; a proven root cause with a test around it stays fixed. That's the whole difference between debugging and getting lucky, and it's entirely a matter of the order you make the model work in.
Related guides
- Setting Up Claude Code for a Team
- Automating Your Workflow With Claude Code Hooks
- Working in Large Codebases With Claude Code
Set up Claude Code the right way
Start free with the open-source starter — a ship skill, an adversarial-reviewer subagent, a bug-hunt workflow, and a CLAUDE.md template. Want the full kit? The Pro Pack adds 6 skills, 3 subagents, 3 workflows, 4 templates and a handbook.