How to Debug Code

Debugging is not staring harder at code. It is shrinking a field of suspects until one explanation survives. This is the workflow I use when the error message is ugly, the deadline is close, and guessing feels tempting.

🎙️ Published & recorded:

01Reproduce before you repair

A bug you cannot trigger is a rumor. Write the shortest exact route from a clean start to the failure: input, command, environment, expected result, actual result. Then run it twice. If the route is unreliable, record what changes between runs before touching code.

# Weak report: "upload is broken"
# Useful reproduction:
1. Start app with an empty data directory
2. Upload a file named report.final.csv, 0 bytes
3. Click Import once
Expected: "empty file" validation
Actual: TypeError: Cannot read properties of undefined (reading 'trim')
My rule
Do not begin with a fix. Begin with a failing recipe. A clean reproduction is already half a diagnosis because it turns an argument about possibilities into an observable event.

02Read the last useful line first

Long traces invite panic and top-to-bottom reading. Resist both. The first lines often describe the wrapper that noticed the crash. Start at the final exception, then move upward to the first frame that belongs to your code. Library internals are witnesses, not usually the crime scene.

Traceback (most recent call last):
  File "app.py", line 41, in <module>
    total = price * quantity
TypeError: can't multiply sequence by non-int of type 'float'

# Inspect the operands, not the multiplication operator:
print(repr(price), type(price), repr(quantity), type(quantity))
# '12.50' is text → convert at the input boundary:
price = float(raw_price)

The message says the runtime saw text where your mental model saw a number. Fix the conversion where data enters the system, not with a random cast beside the crash.

03Cut the search space in half

When a request crosses a browser, API, worker, and database, checking every line is wasteful. Put one observation near the middle. Is the value already wrong there? Keep the guilty half and discard the innocent half. Repeat. This is binary search applied to causality.

# Boundary probes, not twenty random print statements
client payload  ✓ {"count": 3}
API input       ✓ count=3
queue message   ✗ {"count": ""}
worker input      {"count": ""}

# The defect lives between API input and queue message.
# Now bisect only that serializer path.
Strong opinion
“Read everything until it looks suspicious” is not a method. Place probes at boundaries and keep halving. The habit works on code, deployments, configuration, and even historical commits; for Git-specific history search, see Git.

04Log state, identity, and time

A useful log answers which operation, with which inputs, reached which state, and how long it took. “Here” answers none of those. Add a request or job ID so one operation can be followed through interleaved output. Log decisions at boundaries; do not narrate every line.

# Bad
print("got here")

# Useful and searchable
logger.info("invoice_send", extra={
  "invoice_id": invoice.id,
  "customer_id": customer.id,
  "attempt": attempt,
  "elapsed_ms": elapsed_ms,
})

Never log passwords, tokens, cookies, or full payment data. More logs are not automatically more evidence. A flood changes timing, hides the event, and can create a security incident of its own.

05Break where reality diverges

A breakpoint is valuable when you have a question. Stop just before the first wrong value is used, inspect locals and the call stack, then step over the smallest suspicious operation. Conditional breakpoints beat stopping inside a loop ten thousand times.

# Break only on the failing order, not every order:
condition: order.id == "ord_8472"

# Watch the invariant you believe:
order.total >= 0

# Typical discovery:
subtotal = 18.00
credit   = 25.00
total    = -7.00
Use the right instrument
Logs explain failures you cannot pause. A debugger explains local state you can reproduce. Do not force production incidents into a breakpoint workflow, and do not bury a deterministic local bug under permanent logging.

06Build a minimal reproduction

Copy the failing path into a tiny disposable case, then remove inputs, dependencies, and setup one at a time. Run after every removal. The last removal that makes the failure disappear identifies a necessary ingredient. Minimal means every remaining line earns its place.

# Production symptom:
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x96 in position 14

# Minimal proof: the file is Windows-1252, not UTF-8
raw = b"July \x96 August"
raw.decode("utf-8")       # reproduces
raw.decode("cp1252")      # "July – August"

# Repair: detect/declare the source encoding at ingestion,
# then normalize to UTF-8 once.

Do not paste the whole application into an issue and call it a reproduction. Reduction is the investigation; the tiny case is its result.

07Explain it to a rubber duck

Say what the code must do, one operation at a time, without using vague verbs such as “handles” or “processes.” Name the value and its type at each boundary. The moment your explanation skips a step or relies on “obviously,” inspect that step.

# "It checks whether the cache has the user" hides the bug.
if cached_user:
    return cached_user

# Spoken precisely:
# "An empty dictionary means a cached user with no fields,
#  but this branch treats it as no cached value."
if cached_user is not None:
    return cached_user
Why it works
The duck contributes nothing. That is the point. Converting a fuzzy internal story into explicit statements exposes the hidden assumption before another person gets dragged into it.

08Catch Heisenbugs without scaring them off

A Heisenbug changes when observed. Adding a log makes it vanish; debug mode makes it reliable; one machine never sees it. Suspect races, uninitialized state, clocks, cache, resource limits, or code that accidentally depends on iteration order. Preserve timing before adding heavy instrumentation.

# Real intermittent symptom:
FileNotFoundError: [Errno 2] No such file or directory: 'result.tmp'

Thread A: write result.tmp → rename result.json
Thread B: delete result.tmp during cleanup

# Step-by-step repair:
1. Correlate both actions with file ID + monotonic timestamp
2. Reproduce under repeated/concurrent load
3. Make ownership explicit; cleanup ignores in-flight files
4. Rename atomically only after close/fsync
5. Add a stress test that ran thousands of iterations

My stance: adding sleeps is not a concurrency fix. It only moves the race window and sends the bug to a slower customer.

09Prove the fix, not the story

Before editing, make the reproduction fail. After editing, make the same reproduction pass. Then reverse or disable the edit and watch it fail again when practical. That last step catches the embarrassing case where a restart, stale cache, or changed test data fixed the symptom for you.

# The three observations
before patch   → FAIL: duplicate invoice sent
with patch     → PASS: one invoice sent
patch reverted → FAIL: duplicate returns

# Keep the smallest failing case as a regression test.
# Also test adjacent boundaries: zero, one, many; retry; timeout.
A patch is not evidence
“It seems fixed” means the verification was not designed. State which observation would disprove your explanation, and go make that observation.

10The stuck-at-2-a.m. checklist

Use this in order. It is deliberately boring; boring beats clever when you are tired.

□ Can I reproduce it from a clean start?
□ What changed: code, config, data, dependency, environment?
□ What is the exact final error and first frame in my code?
□ Which value first differs from the expected value?
□ Can I halve the remaining search area?
□ Do logs include identity, state, and time without secrets?
□ Would a conditional breakpoint answer a specific question?
□ Can I remove half the reproduction and keep the failure?
□ Could observation be changing timing or order?
□ Did the same case fail before, pass after, and become a test?
□ Did I remove temporary logs, flags, sleeps, and debug access?

Debugging speed comes from refusing to guess. Reproduce, narrow, inspect, explain, prove. The tools change; that sequence does not.