skip to content

In a Go order state machine, Advance leaves the status unchanged after it returns. How do you diagnose that?

level: seniorimportance: should knowfreq 56%

answer

  1. the write went somewhere, just not there
  2. two suspects: the method and the call site
  3. a range loop variable is not the element
  4. print an address at entry and compare
  5. index the slice instead of ranging by value

basics

~20 s

Assume the write landed on a copy. Check whether Advance has a value receiver, and whether the call site works on a copy such as a range loop variable. Confirm by printing the receiver's address with %p at entry and comparing it with the caller's.

solid answer

~50 s

There are two ways a mutation gets lost, and both look like working code. Either `Advance` is declared `func (o Order) Advance()`, so it mutates a copy of the receiver, or the *call site* is on a copy — classically `for _, o := range orders { o.Advance() }`, where `o` is a copy of the element even though `Advance` takes a pointer. Confirm it cheaply: print `fmt.Printf("%p\n", &o)` at the top of the method and compare with `&order` at the call site. Same address means you are on the caller's value; a different address every call means you are mutating a copy. The fixes are to give the method a pointer receiver and to index the slice — `for i := range orders { orders[i].Advance() }` — since slice elements are addressable. No compiler error and no `go vet` check catches this; the code is legal, so review conventions and a test that asserts state after the call are what actually prevent it.

code

go · 15 lines
go
func (o *Order) Advance() {
	fmt.Printf("advance recv=%p\n", o)
	o.Status = next(o.Status)
}

fmt.Printf("caller ord=%p\n", &ord)
ord.Advance() // matching addresses: the mutation lands on ord

for _, o := range orders {
	o.Advance() // recv address differs from &orders[i]: mutating a copy
}

for i := range orders {
	orders[i].Advance() // slice elements are addressable, so this mutates the slice
}

go deeper

for a junior

Recognise the symptom: a method that should change something and does not. Check first whether the method takes a value receiver, and know that a fix means changing it to a pointer receiver.

for a middle

Explain both causes — the receiver form and a copied call-site operand such as a range variable — and show the index-based loop fix, saying why slice elements can be addressed and a loop variable is still a copy.

for a senior

Drive the diagnosis: form the hypothesis, prove it with an address print rather than reasoning, then fix the type as a whole and add a test asserting state after the call. Be clear that no standard tool flags this.

for a principal

Talk about prevention at scale: a house rule of one receiver form per type, naming that makes mutation visible at declaration, and review or test conventions that catch lost writes before they reach a production state machine.

## The symptom A checkout state machine has an `Order` with a `Status` and a total in minor units, and a method meant to move it forward. Everything compiles, tests that only check the return value pass, and in production the status never changes. This is the canonical lost-mutation bug and it has exactly two causes. ## Cause one: the method has a value receiver ```go func (o Order) Advance() { o.Status = next(o.Status) // writes to the copy } ``` The receiver is a parameter, and Go copies parameters. The assignment is legal — you may write to a local copy — so nothing warns you. It runs, it costs a struct copy, and it achieves nothing. The fix is `func (o *Order) Advance()`. ## Cause two: the call site is on a copy This one survives the first fix, which is why people chase it for hours: ```go for _, o := range orders { // o is a copy of the element o.Advance() // compiles as (&o).Advance() — mutates the copy } ``` Even with a pointer receiver, the compiler takes the address of `o`, and `o` is a copy that `range` assigned from the element. Since Go 1.22 each iteration gets its own `o`, which fixed a different bug (closures capturing a shared loop variable) but does not change this one: it is still a copy of the element. The fix is to reach the element itself, which is addressable: `for i := range orders { orders[i].Advance() }`, or to hold `[]*Order` if the collection is genuinely a collection of shared entities. The same trap appears whenever the value passes through anything that copies it — storing an `Order` in a map and calling a mutating method on a retrieved copy, or receiving one from a channel and mutating that. ## The diagnostic that settles it in one run Addresses do not lie. Print the receiver's address at entry, and the caller's address at the call site: ```go func (o *Order) Advance() { fmt.Printf("advance recv=%p\n", o) // ... } fmt.Printf("caller ord=%p\n", &ord) ord.Advance() ``` If the two addresses match, the method is operating on the caller's value and the bug is elsewhere. If they differ — or if a value receiver prints `&o` at a fresh address on every call — you have proved the copy. For a value receiver, printing `&o` at entry *and* just before return also shows that both writes stayed inside a frame that is about to disappear. This is a two-minute answer and it beats reasoning about what 'should' happen. ## Why no tool catches it The compiler cannot object: assigning to a value receiver's fields is meaningful in plenty of code, for instance when the method goes on to return the modified value. `go vet` has checks for related hazards, but a discarded write to a copy is legal, intentional in some code, and not detectable in general. So the guard rails are human and test-shaped: - **One receiver form per type.** If any method of `Order` needs a pointer, all of them take pointers. Then a reviewer only has to know the type, not the declaration of each method. - **Name mutations.** `Advance`, `ApplyFee`, `Cancel` read as state changes; if such a method is declared with a value receiver, that mismatch is visible in the declaration line during review. - **Test the state, not the call.** A test that asserts `ord.Status` *after* `ord.Advance()` fails immediately on both causes. A test that only asserts the returned error passes on both. - **Prefer returning the new value for small value types.** `ord = ord.Advanced()` cannot lose the mutation, because forgetting the assignment is a compile error about an unused value only in some cases — but at least the mutation is visible at the call site. ## The generalisation worth carrying Whenever a Go mutation appears not to happen, ask 'whose copy did I write to?' and then find every copy between the caller's variable and the method body: the receiver form, the loop variable, the map lookup, the channel receive, the function argument. One of them owns the write you lost.

  • Why does the compiler accept a value-receiver method that writes to its receiver?
    Because writing to a local copy is legitimate Go — a method may modify the copy and return it, which is how immutable-style APIs are built. The compiler has no way to tell a deliberate 'modify and return' from a forgotten pointer receiver, so it stays silent and the mistake reaches production.
  • Once fixed, how do you stop it recurring across the team?
    Make the type consistent — if one method needs a pointer receiver, all of them take pointers — so a reader only has to know the type. Name mutating methods so a value receiver on them looks wrong in review. And write tests that assert the observable state after the call rather than only the returned error.
  • The same code path stores Orders in a map. What should you check there?
    Whether the mutation is applied to the value that came out of the map. A value read from a map is a copy, so calling a mutating method on it changes nothing the map holds; the fix is to store pointers, or to write the modified value back explicitly after the call.

saying these in an interview costs you the question

  • Adds a pointer receiver but keeps ranging by value and calls it fixed
  • Blames concurrency for a mutation that never happened at all
  • Claims go vet or the compiler would have caught the lost write
  • Takes &o of the range variable and expects the slice to change
  • Says Go 1.22 per-iteration loop variables fixed this case