Go 1.22 gives loop iterations their own variables
Go 1.22 changes loop-variable identity without silently changing old modules. The proposal's test failures explain both the need for the change and the care in its rollout.
A test can pass under the race detector while failing to check most of its test cases. Go's old loop-variable semantics made that possible in a particularly ordinary table-driven test. A parent test started parallel subtests inside a range loop, each subtest captured the same variable, and every check eventually used the final value. The synchronization was valid. The test's meaning was wrong.
Go 1.22, released on February 6, changes variables declared by loops so each iteration gets its own. The change removes a recurring source of accidental sharing. Its more consequential design decision is how it preserves the behavior of existing modules. A newer compiler does not simply reinterpret every loop it encounters.
I like Go because its restrictions usually make a program easier to understand after the author has stopped thinking about it. Requiring a defensive assignment at the top of an otherwise ordinary loop works against that property. This release improves a common construct while giving module authors control over the semantic change.
The variable was shared even when execution was orderly
The accepted proposal, issue 60078, explains the failure with a test intended to check whether several numbers are even. Its inputs include an odd number followed by even numbers. Each iteration starts a subtest, calls t.Parallel, and checks the captured loop variable.
Calling t.Parallel suspends the subtest until the parent returns. The parent finishes its loop first. Under the old semantics, the range declaration created one variable and assigned each successive value to it. All the closures retained access to that same variable, so the subtests eventually checked the final even number. An odd input earlier in the table did not cause failure.
The design document by David Chase and Russ Cox makes an important distinction here. This example has no data race. The testing package orders the checks after the parent's updates. A race detector cannot report a synchronization violation that does not exist. The problem is the relationship between the programmer's intended test cases and the identity of the variable captured by each closure.
Goroutines are a familiar setting for this mistake, but concurrency is unnecessary. A loop can append closures to a slice and call them later in a single goroutine. It can also append the address of the loop variable to a slice of pointers. Both retain access to a variable after an iteration ends. With the old declaration semantics, later assignments update the same variable.
The conventional repair introduces a new declaration inside the loop body, often written v := v. Its right-hand side reads the current iteration value. A new left-hand variable gives that iteration an independent identity. This is meaningful shadowing, but it makes correctness depend on a line that looks redundant to an unfamiliar reader.
Declaration controls the new semantics
Go 1.22 changes variables declared by the loop, including range declarations and declarations in the initialization clause of a three-clause loop. The proposal describes the range change as giving each iteration a separate variable initialized with its iteration value. Closures created in different iterations therefore retain different variables.
Assignment to a variable declared outside the loop retains sharing. The distinction between := and = remains relevant. A caller can explicitly predeclare a variable if its identity must survive across iterations. This also gives unusual code that deliberately depends on the old behavior a direct way to express that dependency.
Three-clause loops require a more careful account than placing a declaration at the beginning of the body. The first iteration uses the variable declared by the initializer. Before a subsequent iteration's post statement, a new variable receives the preceding iteration's value. The post statement then updates that new variable. The design document spells out this ordering so changes to a loop variable inside the body continue to affect progression through the loop.
Applying the change to both forms avoids a semantic discrepancy when code changes between range and a three-clause loop. Both forms can retain variable addresses or capture variables in closures. Fixing only range would leave the same class of mistake in another standard spelling of iteration.
A separate language addition permits range over an integer. The release announcement illustrates that an integer bound of ten produces iteration values zero through nine. That addition shortens counted loops, but it should be distinguished from the variable-identity change. One introduces another range expression. The other changes what a loop declaration means when its variable escapes an iteration.
The module version carries the compatibility decision
The proposal ties new loop semantics to the module's declared Go version. Packages in modules declaring go 1.22 or later get the new behavior. Existing modules with an earlier declaration retain the old behavior when a newer toolchain compiles them. Dependencies follow their own module declarations.
This arrangement separates acquiring a compiler from adopting a language change. A module owner can install a new toolchain for implementation improvements while retaining the declared language version. Updating the declaration is the explicit step that changes the meaning of affected loops in that module.
That control is useful because the old behavior was sometimes intentional. A closure might deliberately observe a variable updated by successive iterations. New per-iteration identity changes that relationship. The design document includes examples where changing semantics alters otherwise valid behavior, and it treats those examples as reasons for a gradual transition.
The version declaration also has a forward-compatibility role. The design relies on older toolchains refusing to compile code that declares a language version they do not support. Otherwise new source could request per-iteration semantics and receive old behavior from an older compiler. Refusal is preferable to producing a program with a different meaning from the one the module selected.
I consider this use of the module declaration justified because the change concerns correctness in a construct used throughout Go programs. The compatibility rule is explicit enough to explain a mixed dependency graph. Reading the root module's version alone does not establish the loop semantics of every dependency.
Passing tests were incomplete evidence
The proposal reports that Google's internal conversion caused failures in roughly one out of every 8,000 package tests. That figure describes that particular codebase and conversion. It is evidence about the proposal's practical effects, rather than a universal estimate for other projects.
A separate analysis found definite parallel-test mistakes at a higher rate. Many defective tests continued passing after the semantic change because their underlying code was correct. The change made those tests exercise their intended cases. Only some tests had previously hidden a case that should fail.
This explains why the count of newly failing tests understates the amount of corrected behavior. A test that checks only the final case may happen to agree with the expected result for the other cases. Making it check all the inputs increases coverage of the intended behavior without necessarily changing the test result.
The document also identifies a real allocation consequence. A variable retained beyond an iteration may need separate storage for each iteration. One low-level support program had a test forbidding allocations, and the new scope caused its retained variable to escape. Predeclaring the variable outside the loop preserved the intended lifetime. The example gives a concrete reason to keep the compatibility mechanism even when most changed loops improve.
Analysis cannot infer every retained reference
The design document argues that static analysis cannot reliably determine every use of a retained variable address. A pointer can pass through an interface method or an indirect call before some later operation keeps it. Conservative analysis risks reporting many correct loops. Narrow analysis leaves some incorrect loops undetected.
That limitation explains why another warning was insufficient. Requiring programmers to add defensive declarations everywhere also carries a maintenance cost. The proposal's examination of public changes found many added shadowing declarations that were unnecessary. A reader then has to determine whether each line fixes an actual lifetime dependency or merely follows a precautionary convention.
Transition tooling addresses the narrower problem of locating behavior that changes. Compiler diagnostics identify affected loops. A bisection tool repeatedly enables the new semantics for selected loops and narrows a failing test to the relevant declarations. This gives maintainers a location to investigate even when the failure occurs far from the captured variable.
The resulting language rule reduces the amount of lifetime reasoning needed for newly declared loop variables. Deliberate cross-iteration sharing remains expressible through an outside declaration. Existing modules retain their selected semantics, and a module that adopts Go 1.22 can use ordinary loop declarations without the defensive shadowing assignment.