teachyou.ai academy
← All posts
Claude Code

Debugging with Claude Code: A Systematic Approach

Pramod Dutta · Jun 15, 2026 · 13 min read

Why most AI-assisted debugging sessions go nowhere

You paste a stack trace into Claude Code, it proposes a fix, you accept it, the error changes shape but doesn't go away, you paste the new error, it proposes another fix, and forty minutes later you're further from the root cause than when you started. This is the most common failure mode we see in students who are new to agentic coding tools: they treat the model as a slot machine. Pull the lever (paste the error), hope for a payout (a working fix). Sometimes it pays out. Often it doesn't, and the session degrades into a pile of speculative patches, half of which contradict each other.

The problem isn't that Claude Code is bad at debugging. It's that debugging without a method is bad at debugging, whether a human or a model is doing it. Skilled engineers don't randomly try things when production breaks. They form a hypothesis, gather evidence, test the hypothesis, and only then write a fix. Claude Code works the same way when you let it — it just needs you to set up the investigation instead of demanding an instant answer.

This article lays out a systematic approach to debugging with Claude Code: how to frame a bug so the model can actually reason about it, how to use the tool-calling loop (reading files, running commands, searching logs) as an evidence-gathering process rather than a guessing process, and how to close the loop with real verification instead of "looks good to me." We'll walk through a concrete example, show the commands and prompts that make the difference, and point out the traps that turn a five-minute fix into a two-hour thrash.

Start with reproduction, not explanation

The single highest-leverage thing you can do before asking Claude Code to fix anything is get a reliable reproduction. Not a description of the bug — an actual, repeatable trigger. If you can't reproduce it, neither can the model, and it will fall back to pattern-matching against the error text alone, which is exactly how you end up with fixes that don't fix anything.

A reproduction has three parts: the exact input or action that triggers the failure, the exact command or environment used to run it, and the exact output you got versus what you expected. Compare these two prompts:

"The login is broken, can you fix it?"

versus

"Running npm test -- auth.test.ts fails on the 'expired token refresh' case. Expected a 401 with {error: 'token_expired'}, got a 500 with no body. Reproduces every time on main."

The second prompt gives Claude Code a foothold. It can open the test file, find the exact assertion, trace the code path, and start forming a hypothesis. The first prompt gives it nothing but license to guess.

If you don't have a repro yet, that's the first task — not the fix. Ask Claude Code explicitly to help you reproduce it before touching any code:

I'm seeing intermittent 500s on /api/orders in production logs.
Don't fix anything yet. Help me build a minimal reproduction:
what request shape, what state, what's the smallest test that
triggers this locally?

This single instruction changes the whole trajectory of the session. It forces the investigation to happen before the intervention.

Give the model a way to gather evidence, not just read your description

Claude Code's advantage over pasting an error into a chat window is that it can actually go look. It can read the file at the line number in the stack trace, grep for every call site of a function, run the failing test, check git blame on the line that changed last, and read the actual runtime output — not your paraphrase of it.

Use that. Don't summarize the stack trace in your own words; paste the whole thing. Don't describe what the config file says; let Claude Code open it. The fewer things Claude Code has to take on faith from your description, the fewer wrong turns it takes.

A good debugging prompt looks like a case file, not a complaint:

Bug: POST /checkout returns 502 for orders with 10+ line items only.
Stack trace attached below. This started after the "batch pricing"
PR merged (commit a3f9c21). Reproduce with:
  curl -X POST localhost:4000/checkout -d @fixtures/large_order.json

Investigate before proposing a fix:
1. Read the batch pricing diff in commit a3f9c21
2. Find where line item count affects request size or timeout
3. Check if there's a payload size limit or batch size cap involved

Notice this prompt doesn't say "fix the bug." It says "investigate," and it names specific artifacts to look at: a commit, a reproduction command, a hypothesis to check (batch size cap). You're not doing the debugging for the model — you're pointing it at the evidence you already have so it doesn't waste turns rediscovering things you already know.

Use systematic debugging instead of first-plausible-fix

The most common anti-pattern is accepting the first explanation that sounds plausible. Claude Code, like any engineer under time pressure, will sometimes propose a fix that addresses the symptom described in the error message without addressing why that symptom occurred. A TypeError: Cannot read property 'id' of undefined can be "fixed" by adding an optional chaining operator in ten different places — and none of them will explain why the object was undefined in the first place.

A systematic approach forces the hypothesis to be explicit and falsifiable before any code changes:

  • State the symptom precisely (what breaks, under what condition, how often)
  • Form a hypothesis for the root cause (not the fix — the cause)
  • Identify what evidence would confirm or rule out that hypothesis
  • Gather that evidence (read code, add logging, run the repro, inspect data)
  • Only write the fix once the hypothesis is confirmed

Ask Claude Code to work this way explicitly, especially on anything non-trivial:

Before writing a fix, tell me your hypothesis for the root cause
and what evidence in the codebase supports it. If you're not sure,
say what you'd need to check next instead of guessing.

This single sentence changes the character of the response. Instead of a diff, you get a reasoned explanation you can sanity-check yourself before any code is touched. If the hypothesis is wrong, you've caught it before it cost you a bad commit.

Bisect instead of scanning the whole codebase

When a bug appeared "at some point" rather than being present from day one, the fastest path to a root cause is almost never reading the whole system — it's narrowing down when the behavior changed. Claude Code can drive git bisect for you, but it's more effective if you frame it as a search problem rather than a code-reading problem.

git bisect start
git bisect bad HEAD
git bisect good v2.3.0

Then hand the loop to Claude Code:

We're bisecting a regression: checkout total is off by rounding
errors, first noticed after v2.3.0. Run the pricing test suite at
each bisect step and mark good/bad based on whether
`pricing.test.ts` passes. Report the offending commit and what
changed in it.

This is a case where the tool-use loop earns its keep. Claude Code can check out each candidate commit, run the test command, read the result, and mark it — a mechanical process that's easy to get wrong by hand (forgetting to rerun the build, testing the wrong commit) but is exactly the kind of repetitive, evidence-checking loop that suits an agent. The output isn't "I fixed it," it's "commit 7f2e1a4 changed the rounding mode from ROUND_HALF_UP to ROUND_HALF_EVEN in pricing.ts line 88" — a fact you can verify yourself in seconds.

Read the error, not the summary of the error

A specific trap worth naming: when Claude Code reports back what it found, it will sometimes summarize a stack trace or log output rather than showing it verbatim. A summary can quietly drop the one detail that mattered — the specific line number, the exact type mismatch, the one field that was null among twelve that weren't. Ask for the raw evidence when precision matters:

Paste the exact lines from the stack trace you're basing this on,
not a paraphrase. Same for the log output — show me the actual
line, not a description of it.

This matters more than it sounds like it should. In one recent debugging session, a summarized error read "the response is missing the expected field," which sounds like a schema problem. The actual line was TypeError: Cannot read properties of null (reading 'items') — a null-pointer issue that pointed to a completely different fix (a missing guard on an async race condition, not a schema migration). The paraphrase cost twenty minutes chasing the wrong layer of the stack.

Instrument before you guess: use logging and print-debugging deliberately

Sometimes the codebase genuinely doesn't give you enough information to form a hypothesis — you need runtime data you don't have yet. This is where temporary instrumentation earns its keep, and Claude Code is well-suited to adding it precisely and removing it cleanly afterward.

import logging

logger = logging.getLogger(__name__)

def apply_discount(order, discount_code):
    logger.debug(f"apply_discount: order_id={order.id} "
                 f"item_count={len(order.items)} "
                 f"discount_code={discount_code!r} "
                 f"subtotal={order.subtotal}")
    discount = lookup_discount(discount_code)
    logger.debug(f"apply_discount: resolved discount={discount!r}")
    if discount is None:
        raise ValueError(f"No discount found for code {discount_code!r}")
    return order.subtotal - discount.amount

The key discipline here is telling Claude Code explicitly that this instrumentation is temporary and needs to be removed once the root cause is found:

Add debug logging to apply_discount and lookup_discount so we can
see what's happening at runtime — I'll run it and paste the output
back to you. Mark these lines clearly so we remember to strip them
once we've found the cause. Don't touch any other logic yet.

This keeps the investigation phase clean and separate from the fix phase, and it stops you from accidentally shipping a pile of leftover console.log calls into production — a small thing, but it's the difference between a codebase that stays trustworthy after a debugging session and one that accumulates debris.

Fix the cause, verify the fix, then verify it didn't break anything else

Once the hypothesis is confirmed, the fix itself is often the easy part — usually a small, targeted change. The part people skip is verification, and it's the part that actually matters. A fix that isn't verified is a guess with better production values.

Verification has two halves. First, confirm the original symptom is gone using the exact reproduction from step one — not a similar-looking case, the same one:

npm test -- auth.test.ts -t "expired token refresh"

Second, confirm you didn't fix the symptom by breaking something adjacent. This is where a lot of AI-assisted fixes quietly fail: a null check that "fixes" one code path but changes behavior for a caller that depended on the old behavior. Run the broader test suite, not just the one test:

npm test

Ask Claude Code to state, in plain terms, what changed and why it addresses the confirmed root cause — not the symptom:

Run the full test suite, not just the failing test. Then explain
in two sentences: what was the root cause, and how does this diff
address that cause specifically (not just the error message).

If the explanation reads like it's describing the error message rather than the mechanism that produced it, that's a signal the fix is papering over the symptom. Push back and ask it to trace the mechanism again.

Keep a debugging log so the session doesn't repeat itself

Long debugging sessions with Claude Code benefit enormously from an explicit, running record of what's been ruled out. Without one, it's easy to re-propose a hypothesis that was already tested and rejected three turns ago, especially in a long back-and-forth where context gets crowded with file contents and command output.

A simple running list works well, and you can ask Claude Code to maintain it as part of the session:

Keep a short running list as we go: hypotheses tried, what
evidence ruled each one out, and what's still untested. Show it
to me before proposing the next thing to try.

This does two things. It stops redundant work, and it gives you — the human in the loop — a clear audit trail if you need to hand the bug off to a teammate, write up the postmortem, or just remember what happened when you look at the commit six months from now. A commit message that says "fix null pointer in discount lookup" is much less useful than one that says "discount lookup returned null for expired codes because the cache TTL was shorter than the code's validity window — extend TTL to match."

Common traps that undo all of the above

A few failure patterns show up often enough to call out directly:

  • Accepting a fix that changes the error instead of resolving it. If the stack trace looks different after a patch but the underlying test still fails, that's not progress — it's the bug moving to a different line. Treat it as a new symptom of the same unconfirmed hypothesis, not a step forward.
  • Letting the model edit files it hasn't read. If Claude Code proposes a change to a file it hasn't opened in the current session, stop and ask it to read the file first. Edits based on remembered or assumed file contents are a common source of subtle regressions.
  • Skipping the regression check because "it's a small fix." Small fixes touch shared code more often than large ones, precisely because they're small enough to feel low-risk. Run the full suite anyway.
  • Debugging in the same message as the original feature request. Mixing "also can you add X while you're in there" into a debugging session muddies the diff and makes it harder to tell which change fixed the bug. Keep debugging sessions scoped to the bug.
  • Not giving version or environment context. "It works on my machine" bugs are often dependency or environment mismatches. Tell Claude Code your runtime version, package versions, and OS if the bug is at all environment-sensitive — it can't infer what it can't see.

Building the habit

None of this requires exotic tooling. It requires treating Claude Code as a capable investigator that does its best work when you hand it a clear case — a reproduction, the relevant evidence, and an explicit instruction to form and test a hypothesis before writing code. The pattern is the same one that separates senior engineers from junior ones when debugging by hand: resist the urge to patch the symptom, gather evidence deliberately, and verify before declaring victory.

The teams that get the most out of Claude Code for debugging aren't the ones with the cleverest prompts. They're the ones who've turned this into a boring, repeatable checklist: reproduce, investigate, hypothesize, gather evidence, fix the cause, verify broadly, log what you learned. Boring is good. Boring is what makes a fix trustworthy at 2 a.m. when you're not the one reviewing it.

If you want to build this workflow into muscle memory — including how to structure prompts for investigation versus implementation, how to use Claude Code's tool-calling loop for bisection and log analysis, and how to set up verification gates so bad fixes don't slip through — our Claude Code Tutorial for Beginners course walks through the entire debugging lifecycle with real, broken codebases you fix step by step.