skip to content

Why does `for _, p := range players` over a []Player silently lose writes to p?

level: middleimportance: should knowfreq 66%

answer

  1. range assigns, and assignment copies
  2. The element never left the backing array
  3. Ask what type the loop variable has
  4. Index instead of the value variable
  5. Slice of values versus slice of pointers

basics

~20 s

Because range assigns a copy of each element to p. Writing p.HP changes that copy, not the slice element. Fix it by writing through the index, players[i].HP, or by holding a []*Player so the copied value is a pointer.

solid answer

~50 s

`range` over a `[]Player` produces two values per iteration: the index, and a **copy** of the element assigned to the second variable. So `p` is a fresh `Player` that happens to hold the same field values as `players[i]`; `p.HP -= 10` decrements the copy, and at the next iteration that copy is overwritten. Nothing warns you, because assigning to a local variable is perfectly legal code. There are two standard fixes. Write through the index — `for i := range players { players[i].HP -= 10 }` — which mutates the backing array in place. Or hold a `[]*Player`, where the copied value is a pointer and `p.HP -= 10` writes through it to the shared `Player`. Note that per-iteration loop variables, which recent Go gives you, do not help here: a per-iteration `p` is still a copy of the element, not an alias for it.

code

go · 17 lines
go
// players is []Player

// BUG: p is a fresh copy of players[i] each iteration
for _, p := range players {
	p.HP -= 10
}

// FIX 1: write through the index, into the backing array
for i := range players {
	players[i].HP -= 10
}

// FIX 2: hold pointers; the copied loop value is the pointer
// (roster is []*Player)
for _, p := range roster {
	p.HP -= 10
}

go deeper

for a junior

Recognise the shape: if the loop body assigns to the second range variable, the write is going into a copy. Know the index-based rewrite and be able to type it correctly on a whiteboard.

for a middle

Explain it from the language rule — range assigns the element, assignment copies — and contrast a slice of values with a slice of pointers, including why the copy exists at all.

for a senior

Be ready to say why you would not simply switch the slice to []*Player: the extra indirection, the loss of contiguous layout, and the fact that handing out entity pointers is how a concurrent write gets introduced later.

for a principal

Treat it as a data-ownership decision rather than a loop bug: whether entities are owned by one collection and mutated in place, or handed out as pointers, sets what every future caller is allowed to do.

## What `range` actually hands you A two-variable `range` over a slice is defined as: for each index in turn, assign the index to the first variable and **assign the element to the second variable**. That word — assign — is the whole answer, because assignment in Go copies. ```go for _, p := range players { // p is a Player, copied out of players[i] p.HP -= 10 // decrements the copy } ``` After the loop, every `players[i].HP` is untouched. The compiler cannot warn you: assigning to a field of a local variable is legitimate code, and `go vet` does not flag it either. It is one of the most common review findings in Go written by people arriving from languages where a for-each variable aliases the element. ## Why it feels wrong In many languages a collection holds *references* to objects, so the loop variable and the collection entry denote the same object and mutating through either is visible in both. A Go `[]Player` does not hold references — it holds `Player` structs laid out end to end in a backing array. Copying one out is a real copy of every field. The mental correction is: **`[]Player` is a slice of values; `[]*Player` is a slice of references.** ## The two fixes **Index into the slice.** `players[i]` is not a copy — it names the element inside the backing array, so assigning to its field writes to the array: ```go for i := range players { players[i].HP -= 10 } ``` The one-variable form `for i := range players` is also the cheapest loop shape: it never materialises the element copy at all, which matters when the element is a large struct. **Hold pointers.** If the slice is a `[]*Player`, the copied loop value is a pointer, and writing through it reaches the shared struct: ```go for _, p := range players { // players is []*Player p.HP -= 10 } ``` That is not automatically the better design. A `[]*Player` costs an extra indirection per access, spreads the entities across memory instead of packing them contiguously, and — the part that matters for a game-server tick loop — hands every holder of one of those pointers the ability to mutate an entity from anywhere, which is exactly how a concurrent write sneaks in later. A `[]Player` mutated by index keeps ownership in one place. ## What does *not* fix it - **Per-iteration loop variables.** Go 1.22 gave each iteration its own loop variables, which fixed the classic closure-capture bug where every goroutine saw the final value. It changed nothing here: a per-iteration `p` is still a *copy* of the element, not an alias for it. - **Taking the address of the loop variable.** `&p` is the address of the copy. Before per-iteration variables it was also the *same* address every iteration, which produced a slice where every pointer aimed at the last element — a related classic bug. Even now, `&p` points at a copy, so appending it to a `[]*Player` gives you pointers into copies rather than into the slice. Use `&players[i]` when you want a pointer to the element itself. - **Making the field a pointer.** `p.HPPtr` would work, but restructuring the data to work around a loop form is the wrong lever. ## The same rule, everywhere else This is not a `range` quirk; it is Go's uniform value semantics showing up in a loop. The identical copy happens when you write `p := players[0]`, when you pass `players[0]` to a function, and when you send it on a channel. `range` just makes it easy to miss because the copy is implicit in the loop header. One more variant worth knowing: ranging over an **array** (not a slice) evaluates the range expression once and iterates over a copy of the whole array, so even the index form `arr[i] = ...` inside `for _, v := range arr` is reading from a snapshot. Ranging over a **map** yields copies of both key and value, and map elements are not addressable at all, so `m[k].Field = v` does not even compile for a struct-valued map — you must read the value out, modify it, and store it back. ## How to catch it in review The smell is a two-variable `range` whose body **writes** to the second variable. Reading it is fine and idiomatic; writing to it is almost always a bug or a pointless assignment. If a reviewer sees `for _, x := range xs` followed by `x.Something = ...`, that is the finding — ask whether the author meant `xs[i]`.

  • Does giving each iteration its own loop variable fix this?
    No. Per-iteration loop variables, which Go adopted in 1.22, fixed closure capture — goroutines and closures created inside the loop no longer all share one variable. The second range variable is still a copy of the element rather than an alias for it, so writing to it is still lost. The fix remains indexing or a slice of pointers.
  • Why is for i := range players often faster than the two-variable form?
    The one-variable form never materialises the element copy. With a large struct, the two-variable form copies every field on every iteration even if the body reads one of them. If you need the element, `p := &players[i]` gives you a pointer with no copy. This only matters for big elements or hot loops; measure before rewriting readable code.
  • What is wrong with collecting &p from a range loop into a []*Player?
    `&p` is the address of the loop variable, which is a copy of the element, not the element. The resulting pointers refer to copies, so mutating through them never touches the slice, and readers of the slice never see the change. Take `&players[i]` instead, which is a pointer into the backing array.

saying these in an interview costs you the question

  • Says the loop variable aliases the slice element
  • Blames the compiler for optimising the write away
  • Thinks go vet catches this
  • Claims per-iteration loop variables fixed it
  • Collects &p from the loop to get element pointers
  • Converts every slice to pointers as the default fix