CodingDeveloper skill

Systematic Debugging

Root-cause-first debugging: reproduce, isolate, hypothesize, then verify, instead of guessing at fixes.

What it does

The usual way a bug gets fixed is by changing things until the error stops. That often works, and it often moves the bug somewhere else instead of removing it, so the same failure returns next week with a different stack trace.

With this skill active, the assistant will not touch the code first. It makes the failure happen on demand, shrinks it to the smallest place it still shows up, writes down one cause it can actually test, and then runs an experiment designed to prove the cause wrong. Only once the evidence lands does it change anything, and only one thing.

What you get back is a short written trail: the symptom, the exact command that reproduces it, the theories it killed along the way, the confirmed cause in a sentence, the fix, and a rerun of the original failure showing it gone. A regression test comes with it, checked to make sure it fails on the old code.

You end up with a confirmed root cause, a fix you can explain, and the original failure rerun to prove it is gone.

When to reach for it

Use it for any bug worth understanding: a crash, a failing or flaky test, a wrong number that never errors, or the classic failure that only happens in production. It earns its keep most when a fix has already been attempted and nobody can explain why it helped.

Skip it for a typo you can see in the stack trace, and skip it in the first ten minutes of an outage, where reverting the bad deploy and investigating afterward is the honest order.

What's inside

systematic-debugging/
├── SKILL.md
└── references/
    ├── examples.md
    └── techniques.md
SKILL.md
---
name: systematic-debugging
description: Finds the root cause before any code changes - reproduce the failure on demand, isolate it to the smallest place it still happens, state one testable cause, prove or kill it with evidence, then fix and verify against the original reproduction. Use for any bug, exception, failing or flaky test, or behavior that is wrong without erroring.
---

# Systematic Debugging

You are the engineer who refuses to change code before understanding the failure. A fix nobody can explain is a guess, and guesses relocate bugs rather than remove them.

## Workflow

The order matters. Each step produces the evidence the next one needs.

1. **Collect the symptom.** Exact observed behavior versus expected, the full error text and stack trace, the environment, and what changed recently.
2. **Reproduce.** Make the failure happen on demand. Write down the command and conditions that trigger it.
3. **Isolate.** Shrink the space until the smallest input, test, or code path still fails.
4. **Hypothesize.** One specific, testable mechanism. Written down before you look for evidence.
5. **Test the hypothesis.** Run an experiment whose result would kill the hypothesis if it is wrong. Let the result decide.
6. **Fix the cause.** The smallest change that removes the mechanism, not the symptom.
7. **Verify.** Rerun the reproduction, run the neighbors, add a regression test.

```text
Debugging checklist
[ ] Full error text and stack trace read, not paraphrased
[ ] Failure reproduced on demand, command written down
[ ] Reproduction shrunk to the smallest failing case
[ ] One hypothesis written before evidence gathering
[ ] Experiment run that could have killed the hypothesis
[ ] Fix addresses the confirmed mechanism, one change
[ ] Original reproduction rerun and now passes
[ ] Neighboring tests run, regression test added
```

## 1. Collect the symptom

Ask for, or go and find:

- The exact error text and full stack trace. A paraphrase loses the line that matters.
- What was expected instead, in one sentence.
- Steps, inputs, user, tenant, environment, browser, and time of day if it seems to matter.
- The last known good state: which commit, release, or day it worked.
- Frequency: always, or roughly how often.

"It doesn't work" is not a symptom. Push once for a specific observation before starting.

## 2. Reproduce

If you cannot make it fail on demand, you cannot prove you fixed it. Everything downstream depends on this step.

- Start from the reported steps exactly. No improvements yet.
- If it does not fail, close the gap between your environment and theirs one variable at a time: data, config, timezone, locale, feature flag, account, browser, node version.
- For intermittent failures, find the knob that changes the odds: a loop of 100 runs, a fixed random seed, a throttled network profile, a cold cache. A reliable 1-in-5 is a reproduction; a shrug is not.
- Save the trigger as a command you can paste. `pytest tests/billing/test_totals.py --randomly-seed=48213` beats "run the suite a few times".

If the failure resists reproduction after a genuine attempt, stop and add observability instead: structured logs at the boundary, a request id through the stack, an error report with the payload attached. Say plainly that you are collecting evidence rather than fixing.

## 3. Isolate

Shrink until the failing surface is small enough to hold in your head.

- **Binary search the code path.** Disable, stub, or short-circuit half the pipeline. Whichever half still fails contains the cause. Repeat.
- **Binary search history.** If it used to work, `git bisect` between the last good and first bad commit. This is the highest-yield tool available and people forget it exists.
- **Shrink the input.** Remove rows, fields, characters until removing anything more makes the failure disappear. What remains is the trigger.
- **Compare working and broken.** Same code, one works. Diff the two: environment variables, data, versions, permissions, order of execution.
- **Read the stack trace properly.** Bottom frames are your code; top frames are where it blew up. The interesting frame is usually the last one you own.

Write down what you ruled out. It stops you re-checking it at hour three.

## 4. Hypothesize

State one mechanism, specific enough to be wrong:

- Good: "The list renders empty because the filter runs before the fetch promise resolves."
- Good: "Both tests mutate the module-level rules dict, so order decides the total."
- Too vague to test: "Something is wrong with the caching."

One hypothesis at a time. If you are holding three, pick the one that is cheapest to kill and start there.

## 5. Test the hypothesis

Design the experiment so a wrong hypothesis actually fails:

- Add a log or breakpoint that prints the value your hypothesis predicts.
- Force the condition: pin the clock, seed the random, run the two tests in the suspect order, set the flag by hand.
- Change one variable per run. Two changes make the result unreadable.
- If the experiment confirms nothing either way, the experiment was wrong. Redesign it before touching the hypothesis.

When a hypothesis dies, write down what its death rules out, then form the next one. Do not stack guesses on top of a dead theory.

## 6. Fix the cause

- Make the smallest change that removes the confirmed mechanism.
- If the real fix is large and the situation is urgent, mitigate deliberately: revert, flag off, or patch the symptom, and say out loud that this is a mitigation and the cause is still there.
- Resist the tidy-up urge. Refactoring in the same change makes the verification ambiguous.

## 7. Verify

- Rerun the exact reproduction command. The failure must be gone.
- For intermittent bugs, rerun enough times to beat the original odds by a wide margin. A 1-in-5 bug needs far more than five clean runs.
- Run the surrounding tests to check you did not break a neighbor.
- Add a regression test that fails on the old code and passes on the new one. Confirm it fails on the old code, otherwise it tests nothing.

## Report format

```text
Symptom:      what was observed, in one line
Reproduction: the command or steps that fail reliably
Ruled out:    hypotheses killed, and what killed them
Root cause:   the confirmed mechanism, one or two sentences
Fix:          the change, and why it removes the mechanism
Verification: reproduction result, test results, regression test added
```

## Example

**Symptom.** `test_invoice_totals` passes locally, fails in CI roughly one run in three with `AssertionError: 1200 != 0`.

**Reproduce.** The CI log printed the test-ordering seed. Running `pytest tests/billing --randomly-seed=48213` locally failed on the first try, every time. That turned "sometimes in CI" into a one-line command.

**Isolate.** Ran the first half of the ordered suite, then the second. The failure followed a pair: `test_apply_discount` running before `test_invoice_totals`. Either test alone passed.

**Hypothesis 1 (killed).** The CI database is not reset between tests. Experiment: ran the failing pair against a freshly created database. Still failed. Database state is not involved, which also rules out the fixture teardown.

**Hypothesis 2.** Both tests read a module-level `TAX_RULES` dict, and the discount test mutates it without restoring it.

**Test.** Printed `TAX_RULES` before and after `test_apply_discount`. It gained a `{"region": "EU", "rate": 0}` entry that was never removed, and the totals test matched that zero-rate rule first.

**Fix.** The discount test now takes a fixture that deep-copies the rules and restores them in teardown. Two lines, no production change, because the production path never mutates the dict.

**Verify.** Seed 48213 passes. Twenty random seeds pass. Added `test_apply_discount_leaves_rules_unchanged`, confirmed it fails on the old fixture and passes on the new one.

## Rules

- No shotgun debugging. Changing several things at once and seeing green teaches you nothing about which one mattered.
- Reproduce before fixing, verify after fixing. Both, every time.
- If a fix works and you cannot explain why, you have not found the cause. Keep going or say so explicitly.
- Read the actual error before theorizing. The answer is in the trace more often than anyone admits.
- Never delete or weaken a failing test to make it pass. If the test is genuinely wrong, fix the test as its own change with its own reasoning.
- Timestamp and keep the debugging notes. The second person to hit this bug is usually you, six months later.

## Edge cases

- **Cannot reproduce at all.** Do not guess a fix. Add logging, capture a real payload, and say the investigation is blocked on evidence.
- **Flaky test.** Treat flakiness as the bug. Find the shared state, the real clock, the network call, or the ordering assumption. Quarantining without a cause is a decision to fix it later, so label it as one.
- **Heisenbug.** If it disappears under the debugger or with logging added, suspect timing, buffering, or optimization. Switch to recording evidence that does not change the timing, such as ring-buffer logs flushed after the failure.
- **The bug is in a dependency.** Prove it with a minimal script that uses only the dependency. Then pin, patch, or work around it, and link the upstream issue in a comment.
- **Two bugs at once.** If the evidence contradicts itself, you probably have two mechanisms. Split them: fix the one you can prove, then re-reproduce and start over on the remainder.
- **Production is on fire.** Mitigate first, investigate second, and keep the artifacts you need for the investigation before they roll off: logs, a copy of the payload, the deployed commit.
- **Someone already suggested a fix.** Do not apply it until you can state the mechanism it removes. A plausible fix with no confirmed cause is still a guess.

## Files

- `references/examples.md` - three complete bug hunts, each with the killed hypotheses shown: an intermittent empty list, an off-by-one-day date bug, and a 500 that affected one account. Read it to see how much evidence a step should produce before you move on.
- `references/techniques.md` - the isolation toolkit in detail: `git bisect` on a script, input shrinking, differential comparison, logging that does not lie, and running down flaky tests. Read it when the isolate step stalls.

Install it

Claude Code (this project)

Unzip the download into .claude/skills/. The folder is already named right.

Claude Code (every project)

Unzip into ~/.claude/skills/ to use it everywhere.

claude.ai

Upload the zip under Settings, then Capabilities, then Skills (paid plans).

How to call it

Just describe the task. The description line inside the skill does the matching. Or call it directly:

/systematic-debugging help me find the root cause of this bugthis test only fails sometimesit works locally but breaks in productionI fixed it but I don't understand why it workswalk me through debugging this properly

Works beyond Claude

Skills use the open Agent Skills format. The exact same folder works in OpenAI Codex, Gemini CLI, Cursor, GitHub Copilot and other tools that read the format.

Using a plain chat app? Paste SKILL.md into ChatGPT or Gemini as instructions. You lose auto-triggering, but the method still works.

agentskills.io

Pattern credit: obra/superpowers — systematic-debugging

Related skills