Debugging
Overview
Find the true cause of a defect and prevent its recurrence — across app code, tests, data pipelines, ML, and infra. The failure mode to avoid is symptom-patching: changing code until the symptom disappears without understanding why, which moves the bug rather than fixing it.
The Root-Cause Loop
Run this loop; don't skip steps. Most wasted time comes from hypothesizing before reproducing, or fixing before localizing.
- Reproduce — deterministically. If you can't reproduce it, you can't verify a fix. Capture exact inputs, env, versions, and the full error/stack. For intermittent bugs, first make it more frequent (loop it, add load, shrink timeouts) before anything else.
- Minimize — shrink to the smallest input/code that still fails. Delete half, re-run, repeat (delta-debugging). A 5-line repro localizes faster than a 5000-line one and often reveals the cause outright.
- Localize — bound where it happens before asking why. Bisect in space (comment out / binary-search modules) and in time (
git bisect). Read the stack trace top frame first, then the first frame in your code.
- Hypothesize — state a specific, falsifiable cause ("X is null because Y returns None when Z"). Vague hypotheses ("something with async") aren't testable.
- Test the hypothesis — one variable at a time; change something that should confirm/refute it. If the experiment can't distinguish two causes, design a better one.
- Fix — the root cause, minimally. Verify the repro now passes AND that you understand why the fix works (else you may have masked it).
- Prevent — add a regression test that fails without the fix. No regression test = the bug is not done. Then generalize: are there sibling instances of the same class elsewhere?
Core Discipline
- Reproduce before fixing. Never "fix" what you can't observe failing.
- Read the error fully — message, type, and every stack frame. The answer is often literally in it (wrong frame, unexpected value, swallowed cause).
- Check recent changes. Most new failures are recently introduced →
git log, git bisect.
- Question your assumptions. The bug lives in what you're sure is correct. Verify it (print it, assert it) rather than believing it.
- Preserve evidence. Save the failing input, logs, core dump, and seed before you start mutating code.
Bisection Discipline
git bisect run automates finding the introducing commit — far faster than manual marking.
git bisect start
git bisect bad # current commit fails
git bisect good v1.2.0 # last known-good ref
git bisect run ./repro.sh # script: exit 0 = good, 1..124 = bad, 125 = skip (can't test)
# ...git converges to "abc123 is the first bad commit"
git bisect reset
- The repro script must be reliable — a flaky script sends bisect down the wrong branch. Make the repro deterministic first (see flaky section), or bisect will lie.
- Exit codes:
0 good, 1–124 bad, 125 = untestable (skip — e.g. commit doesn't build), >127 aborts.
- Bisect the behavior, not the test file — if the test itself changed,
git bisect run a script that applies today's test to the old code, or pin the test.
- Works on any monotonic property: performance regressions (
exit 1 if bench > threshold), binary-size, output diffs — not just pass/fail.
Differential Debugging
When it works here but fails there (or worked yesterday), diff the two worlds instead of reading code blind. Enumerate and compare, one axis at a time:
- Code/version:
git diff good..bad, dependency lockfile diff (transitive upgrades!), runtime/compiler version, OS/arch.
- Input/data: exact request, dataset, encoding, ordering, size, null/edge presence.
- Environment: env vars, config files, feature flags, secrets, locale/timezone,
PATH, working dir.
- State: DB contents, cache, filesystem, clock, warm vs cold, concurrency level.
The first axis that, when copied from the working world to the broken one, flips the result is your root cause. diff <(working_env) <(broken_env) beats speculation.
"Passes locally, fails in CI" playbook
Almost always an environment or isolation difference, not logic. Check in order:
- Test ordering / parallelism: CI runs a different order or in parallel → shared-state leak. Repro locally: run the full suite (not just the one test), randomize order, and force the CI thread count (
cargo test -- --test-threads=N, pytest -p xdist -n N, jest --runInBand).
- Ambient config leakage: an env var / config file / global you have set locally but CI doesn't (or vice versa). Diff
env. Your local DB has seed data CI lacks.
- Wall-clock / timezone / locale: CI in UTC exposes tests that assume your local TZ; date formatting,
LANG/LC_*, number/decimal separators.
- Filesystem: case-sensitivity (macOS/Windows case-insensitive → Linux CI case-sensitive:
import Foo vs foo), path separators, missing fixture files not committed, permissions, /tmp cleanup.
- Resources/timing: CI is slower/loaded → tight timeouts and
sleep-based waits fail; less memory → OOM.
- Hidden network: a test hits a real service that's reachable locally but blocked/rate-limited in CI.
- Uncommitted state: the fix works locally because of an uncommitted file or a dirty DB. Reproduce in a clean checkout / fresh container.
Flaky Tests — Root-Cause Classes
Flaky = non-deterministic pass/fail with no code change. Classify the cause, then fix the class (retrying is not a fix — it hides the defect, often a real production race):
| Class |
Tell |
Fix |
| Shared mutable state |
fails only with siblings / in a given order |
isolate: fresh fixtures, txn rollback, temp dirs; no globals |
| Test-ordering dependence |
passes alone, fails in suite (or vice versa) |
randomize order to expose; remove inter-test coupling |
| Time/clock |
fails near midnight/DST/month-end, or in UTC |
inject a fake clock; freeze time; never assert on now() |
| Randomness |
fails ~1/N runs |
seed the RNG; log the seed so failures are reproducible |
| Insufficient waits |
fails under load / on slow CI |
wait for a condition (poll/await), never sleep(n) |
| Async races |
order of callbacks/promises varies |
await completion; deterministic scheduling; barriers |
| Network/external |
fails when a service is slow/down |
mock/stub the boundary; hermetic tests |
| Resource leaks |
fails after many tests (FDs, ports, memory) |
close/cleanup in teardown; fresh port per test |
| Unordered collections |
assertion on set/map/dict iteration order |
sort before asserting; use ordered structures |
# Reproduce flakiness: loop until it fails, capture the run
for i in $(seq 1 200); do cargo test flaky_name -- --nocapture 2>&1 | tee /tmp/run || break; done
cargo test -- --test-threads=1 # if this fixes it → shared-state/ordering isolation bug
RUST_LOG=debug cargo test flaky # expose timing/ordering
pytest -p no:randomly / -p randomly --randomly-seed=N # pin/vary order to bisect ordering deps
Heisenbugs — when observation changes the outcome
The bug vanishes under a debugger or with a print added. This is diagnostic, not magic: the observer changed timing or memory layout.
- Timing shift: a
print/breakpoint adds latency that hides a race or reorders async events. → The real bug is a race; use logging that doesn't alter timing (ring buffer, perf/tracepoints, post-mortem logs), or ThreadSanitizer/--race.
- Optimization/UB: works in debug, breaks in release (or vice versa) → undefined behavior, uninitialized memory, aliasing, or a compiler optimization exposing a latent bug. Reach for ASan/UBSan/Valgrind,
-Werror, MIRI (Rust).
- Memory/aliasing: printing a variable forces it to memory (not a register), masking a corruption. → memory tooling, not more prints.
- Rule: if adding observation fixes it, you have a concurrency/UB bug — attack the timing/memory cause, don't ship the print.
printf vs. debugger — pick deliberately
| Use logging/print |
Use a debugger (pdb/gdb/lldb/inspector) |
| Concurrency/timing bugs (breakpoints alter timing) |
Single-threaded, deterministic control flow |
| Distributed / remote / CI / prod (no interactive shell) |
Reproducible locally, need to inspect rich live state |
| Intermittent — need many runs' data |
Complex object graphs, call stacks, conditional breaks |
| Fast inner loops where stepping is impractical |
You don't yet know where to look (step to find it) |
Structured logging beats scattered prints: include the value's identity (user_id=%s got=%r expected=%r), not just "here". Remove or gate debug logging before committing.
Domain Notes
Data pipelines
Bugs: schema drift between stages, null/missing handling, type coercion (precision loss/overflow), encoding (UTF-8), OOM on large data. Techniques:
sample = df.filter("problematic_condition").limit(1000); sample.write.parquet("dbg.parquet") # local repro set
assert df.filter(col("user_id").isNull()).count() == 0, "null user_ids" # assert assumptions at boundaries
df.explain(); df.printSchema() # execution plan + schema drift
Validate at every stage boundary; log sample rows per transform; test edge data (nulls, empty strings, extremes). Check upstream source for schema changes first.
ML models
Distinguish code bugs from model-behavior issues. Training: loss not decreasing (LR/gradient flow), exploding loss (instability), over/underfit. Inference: distribution shift, train/serve skew.
for n, p in model.named_parameters(): # dead layers / vanishing grads
print(n, None if p.grad is None else p.grad.norm().item())
torch.autograd.set_detect_anomaly(True) # locate NaN/Inf source
# Train/serve skew is the #1 silent accuracy killer:
print(train.mean(), train.std(), prod.mean(), prod.std()) # differ ⇒ preprocessing mismatch
⚠ Train/serve skew: most "accuracy dropped in prod" bugs are a preprocessing/scaler mismatch — the scaler/encoder fit at train time wasn't persisted and reused at serving. Save and load transforms with the model. Also check: feature drift, label leakage, and that eval data matches training preprocessing exactly.
Worked Example — the full loop
# SYMPTOM: TypeError: 'NoneType' object has no attribute 'name' at process_user (users.py:45), intermittent.
# 1 Reproduce: which user_ids? Loop the failing endpoint; log inputs → fails only for deleted users.
# 3 Localize: stack points at users.py:45 → user is None.
def process_user(user_id: str) -> dict:
user = get_user(user_id) # 4 Hypothesis: get_user returns None for deleted users
return {"name": user.name} # crash
# 6 Fix root cause (explicit contract), not `user.name if user else ""` (which hides the deletion)
def process_user(user_id: str) -> dict:
user = get_user(user_id)
if user is None:
raise UserNotFoundError(user_id) # caller decides 404 vs skip
return {"name": user.name}
# 7 Prevent: regression test that fails without the guard
def test_process_user_missing():
with pytest.raises(UserNotFoundError):
process_user("deleted-id")
Tooling quick reference
# Python
python -m pdb script.py # interactive; python -m cProfile -o out.prof for CPU
py-spy top --pid PID # sampling profiler, no code change, prod-safe
# Node
node --inspect-brk script.js # break on first line → chrome://inspect
node --stack-trace-limit=100 ... # deeper async traces
# Rust
RUST_BACKTRACE=full cargo run # full trace; RUST_LOG=debug for logs
cargo test -- --test-threads=1 # serialize to expose shared-state flakes
# Systems
strace -f -e trace=file,network CMD # syscalls (Linux) — find missing files, blocked I/O
gdb -p PID / lldb -p PID # attach to a live/hung process; `bt` all threads for deadlocks
tcpdump -i any port 8080 # wire-level; curl -v for HTTP
Verification checklist
1---2name: loom-debugging3description: Systematic diagnosis and resolution of software bugs, test failures, data quality issues, and performance problems.4---56# Debugging78## Overview910Find the true cause of a defect and prevent its recurrence — across app code, tests, data pipelines, ML, and infra. The failure mode to avoid is *symptom-patching*: changing code until the symptom disappears without understanding why, which moves the bug rather than fixing it.1112## The Root-Cause Loop1314Run this loop; don't skip steps. Most wasted time comes from hypothesizing before reproducing, or fixing before localizing.15161. **Reproduce** — deterministically. If you can't reproduce it, you can't verify a fix. Capture exact inputs, env, versions, and the full error/stack. For intermittent bugs, first make it *more* frequent (loop it, add load, shrink timeouts) before anything else.172. **Minimize** — shrink to the smallest input/code that still fails. Delete half, re-run, repeat (delta-debugging). A 5-line repro localizes faster than a 5000-line one and often reveals the cause outright.183. **Localize** — bound *where* it happens before asking *why*. Bisect in space (comment out / binary-search modules) and in time (`git bisect`). Read the stack trace top frame first, then the first frame in *your* code.194. **Hypothesize** — state a specific, falsifiable cause ("X is null because Y returns None when Z"). Vague hypotheses ("something with async") aren't testable.205. **Test the hypothesis** — one variable at a time; change something that should confirm/refute it. If the experiment can't distinguish two causes, design a better one.216. **Fix** — the root cause, minimally. Verify the repro now passes AND that you understand *why* the fix works (else you may have masked it).227. **Prevent** — add a regression test that fails without the fix. No regression test = the bug is not done. Then generalize: are there sibling instances of the same class elsewhere?2324## Core Discipline2526- **Reproduce before fixing.** Never "fix" what you can't observe failing.27- **Read the error fully** — message, type, and every stack frame. The answer is often literally in it (wrong frame, unexpected value, swallowed cause).28- **Check recent changes.** Most new failures are recently introduced → `git log`, `git bisect`.29- **Question your assumptions.** The bug lives in what you're *sure* is correct. Verify it (print it, assert it) rather than believing it.30- **Preserve evidence.** Save the failing input, logs, core dump, and seed before you start mutating code.3132## Bisection Discipline3334`git bisect run` automates finding the introducing commit — far faster than manual marking.3536```bash37git bisect start38git bisect bad # current commit fails39git bisect good v1.2.0 # last known-good ref40git bisect run ./repro.sh # script: exit 0 = good, 1..124 = bad, 125 = skip (can't test)41# ...git converges to "abc123 is the first bad commit"42git bisect reset43```4445- The **repro script must be reliable** — a flaky script sends bisect down the wrong branch. Make the repro deterministic first (see flaky section), or bisect will lie.46- Exit codes: `0` good, `1–124` bad, **`125` = untestable** (skip — e.g. commit doesn't build), `>127` aborts.47- Bisect the *behavior*, not the test file — if the test itself changed, `git bisect run` a script that applies today's test to the old code, or pin the test.48- Works on any monotonic property: performance regressions (`exit 1 if bench > threshold`), binary-size, output diffs — not just pass/fail.4950## Differential Debugging5152When it works *here* but fails *there* (or worked yesterday), **diff the two worlds** instead of reading code blind. Enumerate and compare, one axis at a time:5354- **Code/version:** `git diff good..bad`, dependency lockfile diff (transitive upgrades!), runtime/compiler version, OS/arch.55- **Input/data:** exact request, dataset, encoding, ordering, size, null/edge presence.56- **Environment:** env vars, config files, feature flags, secrets, locale/timezone, `PATH`, working dir.57- **State:** DB contents, cache, filesystem, clock, warm vs cold, concurrency level.5859The first axis that, when copied from the working world to the broken one, flips the result is your root cause. `diff <(working_env) <(broken_env)` beats speculation.6061### "Passes locally, fails in CI" playbook6263Almost always an *environment or isolation* difference, not logic. Check in order:6465- **Test ordering / parallelism:** CI runs a different order or in parallel → shared-state leak. Repro locally: run the full suite (not just the one test), randomize order, and force the CI thread count (`cargo test -- --test-threads=N`, `pytest -p xdist -n N`, `jest --runInBand`).66- **Ambient config leakage:** an env var / config file / global you have set locally but CI doesn't (or vice versa). Diff `env`. Your local DB has seed data CI lacks.67- **Wall-clock / timezone / locale:** CI in UTC exposes tests that assume your local TZ; date formatting, `LANG`/`LC_*`, number/decimal separators.68- **Filesystem:** case-sensitivity (macOS/Windows case-insensitive → Linux CI case-sensitive: `import Foo` vs `foo`), path separators, missing fixture files not committed, permissions, `/tmp` cleanup.69- **Resources/timing:** CI is slower/loaded → tight timeouts and `sleep`-based waits fail; less memory → OOM.70- **Hidden network:** a test hits a real service that's reachable locally but blocked/rate-limited in CI.71- **Uncommitted state:** the fix works locally because of an uncommitted file or a dirty DB. Reproduce in a clean checkout / fresh container.7273## Flaky Tests — Root-Cause Classes7475Flaky = non-deterministic pass/fail with no code change. Classify the cause, then fix the *class* (retrying is not a fix — it hides the defect, often a real production race):7677| Class | Tell | Fix |78| ------------------------- | --------------------------------------------- | --------------------------------------------------------- |79| **Shared mutable state** | fails only with siblings / in a given order | isolate: fresh fixtures, txn rollback, temp dirs; no globals |80| **Test-ordering dependence** | passes alone, fails in suite (or vice versa) | randomize order to expose; remove inter-test coupling |81| **Time/clock** | fails near midnight/DST/month-end, or in UTC | inject a fake clock; freeze time; never assert on `now()` |82| **Randomness** | fails ~1/N runs | seed the RNG; log the seed so failures are reproducible |83| **Insufficient waits** | fails under load / on slow CI | wait for a *condition* (poll/await), never `sleep(n)` |84| **Async races** | order of callbacks/promises varies | await completion; deterministic scheduling; barriers |85| **Network/external** | fails when a service is slow/down | mock/stub the boundary; hermetic tests |86| **Resource leaks** | fails after many tests (FDs, ports, memory) | close/cleanup in teardown; fresh port per test |87| **Unordered collections** | assertion on set/map/dict iteration order | sort before asserting; use ordered structures |8889```bash90# Reproduce flakiness: loop until it fails, capture the run91for i in $(seq 1 200); do cargo test flaky_name -- --nocapture 2>&1 | tee /tmp/run || break; done9293cargo test -- --test-threads=1 # if this fixes it → shared-state/ordering isolation bug94RUST_LOG=debug cargo test flaky # expose timing/ordering95pytest -p no:randomly / -p randomly --randomly-seed=N # pin/vary order to bisect ordering deps96```9798## Heisenbugs — when observation changes the outcome99100The bug vanishes under a debugger or with a print added. This is diagnostic, not magic: **the observer changed timing or memory layout.**101102- **Timing shift:** a `print`/breakpoint adds latency that hides a race or reorders async events. → The real bug is a race; use logging that doesn't alter timing (ring buffer, `perf`/tracepoints, post-mortem logs), or `ThreadSanitizer`/`--race`.103- **Optimization/UB:** works in debug, breaks in release (or vice versa) → undefined behavior, uninitialized memory, aliasing, or a compiler optimization exposing a latent bug. Reach for ASan/UBSan/Valgrind, `-Werror`, MIRI (Rust).104- **Memory/aliasing:** printing a variable forces it to memory (not a register), masking a corruption. → memory tooling, not more prints.105- **Rule:** if adding observation *fixes* it, you have a concurrency/UB bug — attack the timing/memory cause, don't ship the print.106107## printf vs. debugger — pick deliberately108109| Use **logging/print** | Use a **debugger** (pdb/gdb/lldb/inspector) |110| ------------------------------------------------------- | ------------------------------------------------------- |111| Concurrency/timing bugs (breakpoints alter timing) | Single-threaded, deterministic control flow |112| Distributed / remote / CI / prod (no interactive shell) | Reproducible locally, need to inspect rich live state |113| Intermittent — need many runs' data | Complex object graphs, call stacks, conditional breaks |114| Fast inner loops where stepping is impractical | You don't yet know *where* to look (step to find it) |115116Structured logging beats scattered prints: include the value's *identity* (`user_id=%s got=%r expected=%r`), not just "here". Remove or gate debug logging before committing.117118## Domain Notes119120### Data pipelines121122Bugs: schema drift between stages, null/missing handling, type coercion (precision loss/overflow), encoding (UTF-8), OOM on large data. Techniques:123124```python125sample = df.filter("problematic_condition").limit(1000); sample.write.parquet("dbg.parquet") # local repro set126assert df.filter(col("user_id").isNull()).count() == 0, "null user_ids" # assert assumptions at boundaries127df.explain(); df.printSchema() # execution plan + schema drift128```129130Validate at every stage boundary; log sample rows per transform; test edge data (nulls, empty strings, extremes). Check upstream source for schema changes first.131132### ML models133134Distinguish code bugs from model-behavior issues. Training: loss not decreasing (LR/gradient flow), exploding loss (instability), over/underfit. Inference: distribution shift, train/serve skew.135136```python137for n, p in model.named_parameters(): # dead layers / vanishing grads138 print(n, None if p.grad is None else p.grad.norm().item())139torch.autograd.set_detect_anomaly(True) # locate NaN/Inf source140# Train/serve skew is the #1 silent accuracy killer:141print(train.mean(), train.std(), prod.mean(), prod.std()) # differ ⇒ preprocessing mismatch142```143144⚠ **Train/serve skew:** most "accuracy dropped in prod" bugs are a preprocessing/scaler mismatch — the scaler/encoder fit at train time wasn't persisted and reused at serving. Save and load transforms *with* the model. Also check: feature drift, label leakage, and that eval data matches training preprocessing exactly.145146## Worked Example — the full loop147148```python149# SYMPTOM: TypeError: 'NoneType' object has no attribute 'name' at process_user (users.py:45), intermittent.150151# 1 Reproduce: which user_ids? Loop the failing endpoint; log inputs → fails only for deleted users.152# 3 Localize: stack points at users.py:45 → user is None.153def process_user(user_id: str) -> dict:154 user = get_user(user_id) # 4 Hypothesis: get_user returns None for deleted users155 return {"name": user.name} # crash156157# 6 Fix root cause (explicit contract), not `user.name if user else ""` (which hides the deletion)158def process_user(user_id: str) -> dict:159 user = get_user(user_id)160 if user is None:161 raise UserNotFoundError(user_id) # caller decides 404 vs skip162 return {"name": user.name}163164# 7 Prevent: regression test that fails without the guard165def test_process_user_missing():166 with pytest.raises(UserNotFoundError):167 process_user("deleted-id")168```169170## Tooling quick reference171172```bash173# Python174python -m pdb script.py # interactive; python -m cProfile -o out.prof for CPU175py-spy top --pid PID # sampling profiler, no code change, prod-safe176177# Node178node --inspect-brk script.js # break on first line → chrome://inspect179node --stack-trace-limit=100 ... # deeper async traces180181# Rust182RUST_BACKTRACE=full cargo run # full trace; RUST_LOG=debug for logs183cargo test -- --test-threads=1 # serialize to expose shared-state flakes184185# Systems186strace -f -e trace=file,network CMD # syscalls (Linux) — find missing files, blocked I/O187gdb -p PID / lldb -p PID # attach to a live/hung process; `bt` all threads for deadlocks188tcpdump -i any port 8080 # wire-level; curl -v for HTTP189```190191## Verification checklist192193- [ ] Reproduced the failure deterministically before changing code194- [ ] Localized (stack + bisect) — you know the exact line and *why*, not just where the symptom shows195- [ ] Root cause identified and stated; fix addresses it, not the symptom196- [ ] Regression test added that **fails without** the fix and passes with it197- [ ] Checked for sibling instances of the same bug class elsewhere198- [ ] For flaky/CI-only: class identified (isolation/time/order/async/resource) and fixed — not retried away199- [ ] No debug prints / temporary logging / `set_detect_anomaly` left in the committed code