Before you begin
Why this matters
When a program gives a wrong answer, the lines that cause it are usually a few among many. If you can shrink the program until only those lines are left, you can often see the cause yourself, and anyone you ask for help can run it in a minute.
What this helps you reason about
- Write a failure down precisely enough that someone else can make it happen.
- Shrink a failing program one deletion at a time without losing the failure.
- Notice when a change made the failure disappear for a reason that has nothing to do with the cause.
Case file: a bakery sells three bagels, and its program still shows twelve on the shelf. Nothing crashes; a number simply does not change. This lesson shrinks the program until the cause is the only thing left.
Already know this? The short version
- Write the expected result, the observed result, and the exact input before guessing; run the failure yourself.
- List explanations, each with an observation that would rule it out, and run the cheapest test first.
- Delete one piece of code or input at a time and keep a deletion only if the same wrong result remains; a rewrite such as replacing a loop with shelf[0].Stock -= 3 can remove the cause instead of isolating it.
- Here the cause is the range variable: for _, item := range shelf copies each struct into item, so item.Stock -= 3 changes the copy; index the slice with shelf[i] or take &shelf[i].
- Write it up with expected and observed results and the Go version, make it print pass or FAIL, and keep it as a check after the fix.
Words in this lesson (6)
- reproduction
- A program and an input that make a failure happen every time they are run.
- hypothesis
- A possible explanation for a failure, stated so that some observation could show it is wrong.
- minimal reproduction
- A reproduction from which no single piece of code or input can be deleted without the failure disappearing.
- range variable
- The variable that a for ... range loop fills with each element in turn, such as item in for _, item := range items. It holds a copy of the element, not the element itself.
- pointer
- A value that holds the address of a variable, written *int for a pointer to an int. &count makes a pointer to count, and *p reads or changes the variable that p points to.
- regression test
- A check, kept with the code, that fails while a bug is present and passes once it is fixed, so the bug cannot come back unnoticed.
Case file · the bagels never leave the shelf
- Expected
- Bagel stock goes from 12 to 9.
- Observed
- Bagel stock stays at 12.
- Input
- A shelf of rye (5) and bagel (12); a sale of 3 bagels.
Examples are focused snippets; imports, enclosing functions, or classes may be omitted. C examples use C11; Haskell examples use Haskell 2010. Expand the notes for language-specific depth.
01 / The failing case
What exactly is failing?
A bakery program keeps a slice of items, each with a name and a stock count. Selling three bagels should change the bagel stock from 12 to 9. After the sale, the shelf still shows 12. Before guessing at a cause, write down three things: what you expected, what you saw, and the exact input. Together they are the failing case. Then run it yourself before changing anything. A failure you have seen happen is evidence. A failure someone only described to you may depend on something they did not mention, so it is not yet evidence.
Here the failing case is: expected 9, observed 12, input a shelf of rye (5) and bagel (12) and a sale of 3 bagels. In the program, for _, item := range items visits each element of items in turn; the _ throws away the element’s index.
func sell(items []Item, name string, count int) {
for _, item := range items {
if item.Name == name {
item.Stock -= count
}
}
}
// after sell(shelf, "bagel", 3), bagel is 12, expected 9 Write the expected result, the observed result, and the exact input before you look for a cause.
Experiment
Run the failing case, then delete a part
for _, item := range items puts each element of items into the variable item in turn. The subtraction runs on item. When sell returns, the shelf’s elements have not changed.
Why it matters A deletion after which the same wrong result remains shows the deleted piece is not needed for the failure. A change that makes the failure disappear shows something about what triggers it. Both are evidence, as long as you change one thing at a time.
Predict the bagel stock that main prints after the sale. Then make the first deletion: delete {"rye", 5}, from the shelf and run again. Is the failure still there?
package main
import "fmt"
type Item struct {
Name string
Stock int
}
func sell(items []Item, name string, count int) {
for _, item := range items {
if item.Name == name {
item.Stock -= count
}
}
}
func main() {
shelf := []Item{{"rye", 5}, {"bagel", 12}}
sell(shelf, "bagel", 3)
fmt.Println(shelf)
}
What to look for
Step into sell. At the first stop inside the loop, item can show a garbled Name: the variable exists before the loop gives it a value, and the panel shows leftover memory. At the next stops item holds rye, then bagel, and the stepper stops on item.Stock -= count for bagel. When sell has returned, main’s shelf still shows bagel with Stock 12, and the output is [{rye 5} {bagel 12}]. After the edit, the output is [{bagel 12}]: the same wrong stock, so the rye item was not needed for the failure.
Expected output
[{rye 5} {bagel 12}]02 / Explanations
Which explanations fit what you saw?
A hypothesis is a possible explanation, stated so that an observation could show it is wrong. List several before testing any: the first one you think of is often not the cause. For each, write what you would see if it were true, then run the cheapest test first. A test may change the program for a moment; undo the change before the next test. Here are three explanations for the unchanged stock.
Test the competing explanations
Predict first. These are recorded outcomes of the example, not tests running in your browser.
01Step through sell and watch for item.Stock -= count
If the name never matches, for example because of a typo or a capital B in Bagel, the stepper never stops on the subtraction.
Reveal the observation
Observed It stops there once, with item holding bagel and Stock 12.
What this rules in or out The name matches, and the subtraction runs.
02Print item.Stock right after the subtraction
If the subtraction is wrong, it prints 12.
Reveal the observation
Observed It prints 9, and shelf still shows bagel 12 afterwards.
What this rules in or out The subtraction works, but on a value that is not the shelf’s element.
03Replace the subtraction with items[1].Stock -= count
Go passes every argument as a copy. If sell received its own copy of the elements, shelf still shows 12.
Reveal the observation
Observed shelf shows bagel 9.
What this rules in or out items shares its elements with shelf. The copy is item, not the slice.
An explanation is worth testing when some observation could rule it out.
03 / Reduce
What can you delete and still see the failure?
Reduce in small steps. Delete one piece: a function, a line, a field, an element of the input. Run again and compare the result with the failing case. If the same wrong result appears, keep the deletion. If the result changes, undo the deletion: that piece is part of the cause, and you have learned something about it.
Only delete while you reduce. Rewriting a line to make it simpler can change what the code does, and then a failure that disappears tells you nothing. Treat the input the same way: shorten it, and check every time that it still fails. You are done when no single deletion keeps the failure. That is a minimal reproduction.
Here the rye item, the Name field, the name test, and the sell function all go, and the failure stays. The input shrinks to one item. Notice what is not a deletion: replacing the loop with shelf[0].Stock -= 3 writes to a different value, and the experiment shows what that does.
shelf := []Item{{12}}
for _, item := range shelf {
item.Stock -= 3
}
fmt.Println(shelf) // [{12}], expected [{9}] Keep a deletion only if the same wrong result is still there.
Precisely: minimal is not smallest
Minimal here means that no single deletion keeps the failure. A different order of deletions can end at a different minimal program, and a cleverer rewrite might be shorter still. Minimal is enough: every line left is needed for the failure, so every line is a clue. Automatic tools that shrink inputs this way exist for many languages; the general method is called delta debugging.
Experiment
Rewrite the loop and lose the failure
item is a separate variable that receives a copy of each element. shelf[0].Stock names the element itself. The rewrite looked like the same work, but it changed which value is written, so it removed the cause instead of isolating it.
Why it matters While reducing, only delete. A rewrite that makes the failure disappear may have removed the cause, and then it tells you nothing about the original code.
Predict the output. Then replace the whole for loop, all three lines, with the single line shelf[0].Stock -= 3, which looks like the same work, and run again.
package main
import "fmt"
type Item struct {
Stock int
}
func main() {
shelf := []Item{{12}}
for _, item := range shelf {
item.Stock -= 3
}
fmt.Println(shelf)
}
What to look for
Inside the loop, item shows Stock 12 at its only stop; the stepper moves on before item changes. The output is [{12}]. After the edit, shelf shows Stock 9 at the next stop and the output is [{9}]: the failure is gone. The new line writes to the shelf’s element; the loop wrote to item.
Expected output
[{12}]04 / The rule
Which rule makes the smallest program fail?
Once the program is small, step through it and find the first line where a value differs from what you expected. Then look up the rule for that line in the documentation.
Here the first surprising value is item.Stock: it becomes 9 while shelf[0].Stock stays 12. The rule: in for _, item := range shelf, item is a variable of its own, called the range variable, and each pass of the loop copies the next element into it. Changing item changes the copy. To change the element, index the slice, or take a pointer: &shelf[i] is the address of the element, and a change made through that pointer changes the element itself.
The bug is not in range: it does what the Go specification says. The bug was the assumption that item is the element. Many reductions end like this, at a language rule that behaves as documented.
for i := range shelf {
shelf[i].Stock -= 3
}
fmt.Println(shelf) // [{9}] The cause is the first line where a value differs from what you expected; the rule for that line explains why.
Coming from Python: In Python, for item in items gives each object itself, so item.stock -= 3 changes the list’s object; Go’s range copies each struct into item.
Coming from C#: C# refuses to compile a change to a struct field through a foreach variable; Go compiles it and changes a copy.
Precisely: what range assigns
The Go specification says the range clause assigns each element to the iteration variables as an assignment statement would, which copies a struct. Since Go 1.22 each pass of the loop has a new item variable; before that, one variable was reused. In both versions it holds a copy. The compiler accepts this code without an error, because assigning to a field of item counts as using item.
Experiment
Change a copy, then change the element
item := shelf[0] copies the whole struct into a new variable. A range loop does the same for every element. item := &shelf[0] stores the element’s address instead, so item.Stock reaches the element itself.
Why it matters When a change seems to vanish, check whether you changed a copy. In Go, assigning a struct, passing it to a function, and ranging over a slice of structs all copy it.
Predict both numbers. Then change item := shelf[0] to item := &shelf[0] and predict again.
package main
import "fmt"
type Item struct {
Stock int
}
func main() {
shelf := []Item{{12}}
item := shelf[0]
item.Stock -= 3
fmt.Println(item.Stock, shelf[0].Stock)
}
What to look for
After item.Stock -= 3, the panel shows item with Stock 9 and shelf still with Stock 12; the output is 9 12. After the edit, item shows an address, an arrow, and main.Item {Stock: 12}: item is a pointer to the element, and the subtraction changes shelf too: shelf shows Stock 9, and the output is 9 9.
Expected output
9 1205 / Write it up
Can someone else run your reproduction?
A reproduction is finished when someone else can run it and see the failure without asking you anything. Write four things: what you expected, what happened, the program, and where you ran it (the language version and the system). Make the program print its own verdict, pass or FAIL, so a reader does not need to know the right answer to read the output.
After the fix, keep the same program as a regression test. A regression is a bug that comes back after it was fixed, and a regression test is a check that fails while the bug is present and passes once it is fixed. If someone later brings the bug back, the check prints FAIL again.
// Expected 9, observed 12. Go 1.27.
package main
import "fmt"
type Item struct{ Stock int }
func main() {
shelf := []Item{{12}}
for _, item := range shelf {
item.Stock -= 3
}
if shelf[0].Stock == 9 {
fmt.Println("pass")
} else {
fmt.Println("FAIL: stock is", shelf[0].Stock)
}
} A reproduction prints its own verdict, so anyone can run it and tell whether the bug is still there.
Why is this a better reproduction to send than the program you started with?
Reveal the reasoning
It has nothing left to delete: one item, one field, one loop. A reader sees at once that the loop is the only place the stock could change, and does not have to wonder whether the name test or the rye item matters. It also prints FAIL without the reader knowing the right answer, and after the fix it prints pass.
06 / Try it
Can you shrink a fresh failure?
Predict the output, then plan the reduction before you reveal the answer. A quiz app should add 10 bonus points to every player on a team.
package main
import "fmt"
type Player struct {
Name string
Score int
}
func main() {
team := []Player{{"Ana", 3}, {"Ben", 5}}
for _, p := range team {
p.Score += 10
}
fmt.Println("bonus added")
fmt.Println(team)
}
What does this print, which lines can you delete and still see the failure, and what is the smallest input that still fails?
Reveal the reasoning
It prints bonus added, then [{Ana 3} {Ben 5}]: nobody got the bonus. You can delete the bonus added line, the Name field, and Ben, and the same unchanged score stays. The smallest input is one player, because the failure happens to every element. The rule is the range variable: p is a copy of each Player. The fix is for i := range team { team[i].Score += 10 }.
Practice tickets
- REPRO-101 · Fix the longest streak → Free
- REPRO-102 · Pin the parking charge with a test → Sign in · Pro
- REPRO-103 · Shrink a crashing formula → Sign in · Pro
Keep exploring
Assertions, tests, and counterexamples →