Why did closures created in a Go for loop all observe the last iteration's value before Go 1.22?
answer
- one variable, reassigned N times
- the callbacks all read it after the loop
- shadow it to get a per-iteration variable
- the language changed the loop in 1.22
- gated on the package's language version
basics
~20 sBefore Go 1.22 a for loop declared its loop variables once and reassigned them each iteration, so every closure captured that one shared variable and read its final value. Since Go 1.22 each iteration declares its own copy.
solid answer
~50 sUp to Go 1.21 the variables in a `for` header were declared once for the whole loop and assigned a new value on each pass. Closures capture the variable, not its value, so every literal built inside the body closed over the same variable and, when called after the loop, all read whatever was left in it — the last element. The classic symptom is a scheduler that registers one callback per entry and then logs the same entry on every fire. The pre-1.22 fix was to create a fresh variable per iteration, usually by shadowing: `attempt := attempt` as the first line of the body, or passing the value as a parameter to the literal. Go 1.22 changed the spec so both three-clause and range loops declare their variables per iteration, which fixes the pattern by default; the behaviour follows the language version a package is built at, so older modules keep the old rule.
code
go · 9 linesvar jobs []func()
for _, attempt := range []int{1, 2, 3} {
jobs = append(jobs, func() { fmt.Println(attempt) })
}
for _, j := range jobs {
j()
}
// Go 1.22+: 1 2 3
// Before 1.22: 3 3 3 — every literal captured one shared variablego deeper
Recall the symptom and the one-line fix: before Go 1.22 the loop had a single variable, so shadow it with v := v inside the body. Know that Go 1.22 made it per-iteration.
Explain the two facts that combine to produce it, show a sequential example without goroutines, and state that the 1.22 semantics follow the package's language version rather than the installed toolchain.
Be ready to hunt it in a codebase that spans module versions: what vet does and does not catch, how you would grep for closures registered in loops, and how you would verify a fix without relying on timing.
Own the migration story — when the team moves its language version line, what changes silently, which packages you review first, and how you keep a mixed-version codebase from teaching two different rules to new engineers.
## The symptom A scheduler daemon walks a queue of retry attempts and registers one callback per attempt: ```go var jobs []func() for _, attempt := range attempts { jobs = append(jobs, func() { log.Println("retrying", attempt) }) } ``` Run the jobs later and, on Go 1.21 or earlier, every single log line names the *last* attempt in the queue. Not one line per attempt — the same value, repeated. This is the single most reported Go surprise, and the diagnostic is exactly that log: identical output from callbacks you know were built from different data. ## The mechanism Two facts combine. **Fact one: a function literal captures the variable, not its value.** The literal above does not copy `attempt` when it is created. It holds a reference to whatever variable `attempt` names, and it reads that variable when it runs. **Fact two (pre-1.22): the loop declared its variables once.** In `for _, attempt := range attempts`, `attempt` was one variable belonging to the whole loop statement. Each iteration assigned the next element into it. So all N literals captured the same single variable. By the time anything called them, the loop had finished and that variable held the final element. Nothing about `append`, about slices, or about goroutines is involved. The example above is completely sequential and deterministic. Concurrency merely makes the bug louder, because a goroutine version prints the last value repeatedly *and* nondeterministically. ## The pre-1.22 fixes Both work by creating a new variable per iteration, which is the only thing that helps: ```go for _, attempt := range attempts { attempt := attempt // a fresh variable, scoped to this iteration jobs = append(jobs, func() { log.Println("retrying", attempt) }) } ``` The shadowing line `attempt := attempt` declares a new variable inside the body, initialised from the loop's variable. The literal captures the new one, which is never reassigned. The alternative is to pass the value in: ```go for _, attempt := range attempts { jobs = append(jobs, makeJob(attempt)) } ``` Here `makeJob`'s parameter is the fresh per-call variable. Same principle, better readability, because the capture is now visible in a signature. ## What Go 1.22 changed Go 1.22 changed the language: variables declared by a `for` statement are now created anew on each iteration, for both range loops and three-clause loops. The example at the top now logs each attempt exactly once, with no shadowing line. For a three-clause loop such as `for i := 0; i < n; i++`, each iteration still gets its own `i`; the iteration's variable is initialised from the previous iteration's final value before the post statement runs, so the loop counts exactly as it always did — only the identity of the variable a closure grabs has changed. The change is gated on the language version a package is compiled at, so a module that has not moved its version line keeps the old, shared-variable behaviour. That is why the same source file can behave differently in two repositories, and it is the first thing to check when a colleague cannot reproduce your result. ## Why this was safe to change Go's compatibility promise usually forbids changing the meaning of existing programs. This one went through because the old semantics were essentially always a bug when observed: a program that could tell the difference was a program capturing the loop variable, and virtually all of those were wrong. The Go team measured the fallout and shipped it behind the per-package language version so nothing changed under an unmodified module. ## Tooling `go vet` carries a `loopclosure` analyzer, but it never covered this fully: it reports the classic shapes — a function literal referencing the loop variable as the last statement of a loop body, launched with `go` or `defer` — and cannot see a closure you append to a slice or hand to a registration API. Do not treat a clean vet run on an older module as evidence that no closure captures a shared loop variable. ## What to say in an interview Name the two facts (capture is by variable; the loop used to have one variable), show the symptom, give the shadowing fix, and then state the 1.22 change *with its gating*. Candidates who say only 'Go fixed that in 1.22' miss the half that matters when you maintain a codebase spanning several module versions. ## Related traps that are not this one A closure over a variable declared *outside* the loop is still shared, in every version of Go — 1.22 changed only variables declared by the `for` statement itself. And ranging over a slice hands the body a copy of each element, so mutating the loop variable never writes through to the slice; that is a different rule and it is unchanged.
- Does the Go 1.22 per-iteration rule apply to three-clause for loops as well as range loops?Yes, both. In `for i := 0; i < n; i++` each iteration now has its own `i`, initialised from the previous iteration's final value before the post statement runs. The loop counts identically; the difference is only that a closure created in iteration k keeps iteration k's variable rather than a shared one.
- Why can two colleagues see different output from the same loop on the same Go toolchain?Because the semantics follow the language version the package is compiled at, not the installed toolchain. A module still declaring an older version keeps the shared loop variable, so the same source in a module that has moved to 1.22 or later behaves differently. Check the module's declared version before debugging further.
- Does go vet catch closures capturing a shared loop variable?Only partly. The loopclosure analyzer flags the well-known shapes — a literal referencing the loop variable in a `go` or `defer` as the last statement of the body — and cannot see a closure appended to a slice or passed to a registration function. A clean vet run on an older module is not proof.
- Did Go 1.22 also fix closures over a variable declared just above the loop?No. The change covers only variables declared by the for statement itself. A variable declared before the loop and assigned inside the body is still one variable, so every closure over it still observes its final value. If you need per-iteration state there, declare it inside the body.
Handing out your business card with a whiteboard number written on it: everyone who calls later reads whatever number is on the board now, not the one that was there when they took the card.
saying these in an interview costs you the question
- Blames append or the slice rather than the shared variable
- Thinks the bug only exists when goroutines are involved
- Says the closure copies the value when it is created
- Claims Go 1.22 changed behaviour for every existing module
- Suggests a sleep or a wait as the fix