Step 14 of 24

Debugging

Find bugs methodically: reproduce, shrink, form and test hypotheses, use a debugger, bisect history, and lock the fix in with a test.

16 min 8 sections

Every engineer spends a large share of their time on code that doesn't do what they expect. The difference between someone who fixes a bug in twenty minutes and someone who spends two days on it is rarely intelligence — it's method. Debugging is applied science: observe, form a hypothesis, run an experiment that could disprove it, repeat. This lesson turns that into a concrete process, with the tools that speed up each step in Go and in the browser, and the habits that stop the same bug from coming back.

Step 1: reproduce it

You can't fix what you can't see, and you can't confirm a fix for a bug you never reproduced. Before reading any code, get the bug to happen on demand:

  • Collect the facts. Exact input, exact output, expected output, time, environment, version, user. "Checkout is broken" isn't a bug report; "POST /checkout returns 500 for carts with a voucher and more than 10 items since release 4.12" is.
  • Find the smallest reproduction. Remove everything that doesn't matter: other items, other fields, other services. A reproduction with three inputs is ten times easier to reason about than one with thirty.
  • Write it as a failing test as soon as you can. The test is your experiment, runs in seconds, and becomes the regression test once the bug is fixed — the TDD lesson calls this "red first".

If you can't reproduce it, that's information too. Bugs that only appear in production often depend on something your local setup lacks: real data volume, concurrency, a particular configuration, a time zone, a slow network, or an older client. List the differences and test them one by one.

Step 2: read the evidence

Most bugs announce where they are, if you read carefully.

Read the whole error message and stack trace, from the top. In Go, a panic prints the goroutine and the call stack with file and line numbers:

text
panic: runtime error: index out of range [3] with length 3

goroutine 1 [running]:
main.lastThree(...)
	/app/report.go:42
main.buildReport({0xc000010018, 0x3, 0x3})
	/app/report.go:27 +0x45
main.main()
	/app/main.go:11 +0x2f
exit status 2

That tells you what failed (index 3 on a length-3 slice), where (report.go:42), and how you got there. Look for the first frame in your code; frames inside libraries are rarely where the bug is.

Read the logs and traces around the failure. Structured logs with a request ID let you follow one request through every service (Observability covers this). Look for the first thing that went wrong, not the loudest — a timeout error at the edge is often a symptom of a slow query three services down.

Check what changed. Most new bugs are caused by recent changes: a deploy, a config change, a dependency upgrade, a data migration, or traffic from a new client. "What changed around the time this started?" solves a surprising share of incidents.

Step 3: hypothesise and test

Now think. State a specific hypothesis — "the voucher discount is applied twice when the cart has more than 10 items because the second page of items re-runs the promotion" — and design an experiment whose result would disprove it. Good experiments:

  • Add a targeted log line or assertion and rerun the reproduction.
  • Change exactly one input and see if the behaviour changes.
  • Call the suspected function directly in a test with the suspect input.

Write each hypothesis and its result down. It sounds tedious, but it stops you going in circles retrying ideas you've already ruled out, and it's invaluable when you hand the bug to someone else or write up an incident.

Divide and conquer. If you don't know where the bug is, cut the search space in half. Is the data already wrong when it reaches the handler, or does the handler corrupt it? Check the midpoint, then recurse into the half that's wrong. This turns a search through 2,000 lines into about 11 checks — binary search, applied to code.

Explain it to a rubber duck. Describing the code line by line, out loud, to anyone (or anything) forces you to state assumptions you'd otherwise skip. You'll often find the bug mid-sentence. Pairing gives you a duck that talks back.

Step 4: use the right tool

Adding a log line is the fastest experiment there is, and perfectly respectable. In Go, fmt.Printf("%+v\n", order) prints field names; %#v prints Go syntax, which shows the difference between nil and an empty slice. Remove temporary prints before committing, or turn the useful ones into proper structured logs.

A debugger

When you need to inspect many values or step through logic, a debugger beats a dozen print statements. Go's debugger is Delve; editors (VS Code with the Go extension, GoLand) drive it through a UI, or you can use the CLI:

bash
go install github.com/go-delve/delve/cmd/dlv@latest
dlv test ./internal/pricing -- -test.run TestFare   # debug one test
text
(dlv) break pricing.go:31      # stop at a line
(dlv) continue                 # run until a breakpoint
(dlv) print order.Items        # inspect a value
(dlv) next                     # step over the current line
(dlv) step                     # step into a call
(dlv) stack                    # show the call stack

Conditional breakpoints (condition 1 len(order.Items) > 10) stop only on the interesting case, which matters when the bug appears on the 10,000th iteration. In the browser, DevTools offers the same: breakpoints in the Sources panel, a debugger; statement in code, conditional breakpoints, and the Network panel to see exactly what was sent and received.

git bisect

When something worked in an older version and doesn't now, you don't need to understand the code to find the cause. git bisect binary-searches history: you mark a good and a bad commit, and Git checks out the midpoint for you to test. With a script that exits non-zero on failure, it runs unattended:

bash
git bisect start
git bisect bad HEAD
git bisect good v4.11.0
git bisect run go test ./internal/checkout -run TestVoucherLargeCart
git bisect reset

A thousand commits take about ten test runs. See the git bisect note for details.

Specialised tools

  • The race detector (go test -race) for bugs that appear only sometimes under concurrency — see data races.
  • Profilers (pprof in Go, the Performance panel in browsers) for "it's slow" bugs. Measure before guessing; the slow part is rarely where you think.
  • EXPLAIN ANALYZE for slow queries — Reading query plans.
  • curl -v or the browser's Network panel to see the actual HTTP request and response, headers included.

Common bug families

Knowing the usual suspects speeds up hypothesis forming:

SymptomOften caused by
Works locally, fails in prodconfig/env differences, data volume, time zones, concurrency
Fails intermittentlyrace conditions, timeouts, test order dependence, caching
Off by one item< vs <=, inclusive vs exclusive ranges, 0- vs 1-based indexes
Wrong amount by a tiny fractionfloating-point money
Nil pointer / undefinedmissing guard for an optional value, an error ignored earlier
Started after a deploythe diff in that deploy, or a migration it ran
Slow for some users onlyan N+1 query or missing index that only bites with lots of data

Step 5: fix the cause, then lock it in

Fix the root cause, not the symptom. If a nil pointer panics because an upstream function ignored an error, adding a nil check where it crashes hides the bug; handling the error where it was ignored fixes it. Asking "why?" a few times in a row ("why was it nil? because the lookup failed. Why did it fail silently? because the error was discarded…") usually leads to the real fix.

Then:

  1. Keep the failing test as a regression test. It should fail without your fix and pass with it — check both.
  2. Look for siblings. If one handler ignored this error, grep for others with the same pattern.
  3. Ask why it wasn't caught earlier, and whether a cheap guard helps: a lint rule, a type (a Money type instead of float64), a test, an alert.
  4. For production incidents, write a short blameless postmortem: what happened, impact, timeline, root cause, and actions. The incident response lesson covers the process.

Practice

A teammate reports: "Since yesterday, the daily sales report shows totals slightly higher than the payments dashboard — but only for some days." The report is generated by a Go job that sums orders between midnight and midnight. List the hypotheses you would form, in the order you'd test them, and the experiment for each.

Show solution

A reasonable order, cheapest and most likely first:

  1. Something changed yesterday. Check deploys, config changes, migrations and dependency upgrades for the report job and the dashboard around the start time. Experiment: read the deploy log and the diff; rerun the report on yesterday's data at the previous version.
  2. Time zone or boundary mismatch. "Midnight" in UTC vs local time (WIB is UTC+7) would shift orders between days, making some days higher and others lower. Experiment: compare the report's query bounds with the dashboard's; check whether the per-day differences sum to roughly zero across a week. Also check inclusive vs exclusive bounds: an order at exactly 00:00:00 counted in both days.
  3. Refunds or cancelled orders. The report may count orders that the payments dashboard excludes (or vice versa). Experiment: on a day with a difference, list orders in the report but not in the dashboard and look at their statuses.
  4. Duplicates. A retry or a join that fans out (orders joined to line items or payments) could count an order twice. Experiment: SELECT order_id, count(*) ... GROUP BY order_id HAVING count(*) > 1 on the report's query.
  5. Floating-point accumulation. Summing many float64 amounts can drift by small amounts. Experiment: sum the same rows as integers in minor units and compare.

For each, the first step is to pick one affected day and produce the list of differing orders. Once you can see which orders differ, the cause is usually obvious — this is the "shrink the reproduction" step applied to data. Then fix the cause, add a test for the boundary or status rule, and keep it.

Key takeaways

  • Debug like a scientist: reproduce, observe, hypothesise, run an experiment that could disprove it, repeat — and write it down.
  • Shrink the reproduction and capture it as a failing test before changing code.
  • Read the full error and stack trace, follow the request through logs, and ask what changed.
  • Divide and conquer when you don't know where the bug is; use git bisect when you know when it started.
  • Use the right tool: prints for quick checks, Delve or DevTools for stepping, the race detector, profilers and query plans for their bug families.
  • Fix the root cause, keep the regression test, and look for the same bug elsewhere.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.Why should you turn a bug's reproduction into a failing automated test before fixing it?

  2. 2.A feature worked in release 3.0 and is broken in 3.4, with about 500 commits in between. What is the most efficient way to find the commit that broke it?

  3. 3.A service crashes with a nil pointer dereference because an earlier repository call's error was ignored and its zero-value result used. What is the root-cause fix?

  4. 4.A test fails roughly one run in twenty, with no code changes between runs. Which cause is most likely, and which tool helps?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.