go
Go changed a loop, and nothing broke

Go 1.22 was released on the sixth of February, and the headline is that the
variables declared by a for loop are now created fresh on each iteration rather
than once and updated.
That is a change to the meaning of code that already exists and already compiles. Which is normally the one thing a language with Go's compatibility promise does not do, so the reason it was acceptable is the actually interesting part.
The bug it removes
Every Go tutorial contains a warning about this, which is the first clue.
for _, id := range ids {
go func() {
process(id) // pre-1.22: which id?
}()
}
Before 1.22, id is one variable that the loop updates. The goroutines close over
that single variable, so they see whatever value it holds when they happen to run —
usually the last one, sometimes not, depending on scheduling. The same trap catches
anything that captures the variable and outlives the iteration: defer, a closure
appended to a slice, a callback registered with something else.
The fix everyone learned was to shadow it:
for _, id := range ids {
id := id // the line nobody could explain to a newcomer
go func() { process(id) }()
}
That line appears in an enormous amount of Go code, does nothing visible, and is impossible to justify to someone learning the language without a paragraph about variable lifetime.
In 1.22 the original version is correct and the shadowing line is redundant.
Why they were able to do it
The obvious objection: some program somewhere relies on the old semantics, and changing it silently breaks that program.
Two things made it defensible. The first is that almost every program relying on the old behaviour was relying on it by accident — the old semantics produce surprising results and the surprise is nearly always a bug. This is not a case of taking away something people were deliberately using.
The second is the mechanism, and it is the part I would steal. The new behaviour is
tied to the language version declared in go.mod. A module that says go 1.21
keeps the old semantics even when compiled by a 1.22 toolchain. Nothing changes
until you edit that line, which is a deliberate act by a person who can then run
the tests.
So the upgrade path is: install the new toolchain, change nothing, everything
behaves as before. Later, bump the language version in go.mod as its own commit,
and that commit is where the semantic change lands. It is reviewable, it is
revertible, and it is separated from the toolchain upgrade that would otherwise
have carried it invisibly.
That is a much better answer than a flag, because a flag is a global setting that someone eventually turns on for the wrong module. Per-module versioning means the blast radius of the decision is the module that declared it.
The other one I have used more
Less discussed and, for me, more frequent:
for i := range 10 {
fmt.Println(i)
}
Ranging over an integer. for i := 0; i < 10; i++ is not hard to write, but it has
three places to make a mistake and this has none. Most of the loops I write that
are not over a collection are over a count, and this is the version I now reach
for.
1.22 also ships a preview of range-over-function iterators, which lets a type
expose a sequence that range can consume without materialising it into a slice.
That is the one I am watching rather than using — it is a preview, and iterator
conventions are the sort of thing that wants to settle before a codebase commits
to them.
What I took from it
Mostly the versioning mechanism, because it generalises.
The reason legacy behaviour survives in languages for decades is that fixing it
means breaking somebody, and there is no way to break them at a moment of their
choosing. Tying semantics to a version declared in the project's own manifest turns
a breaking change into an opt-in with a natural review point. Go got there because
go.mod already had a language version in it for other reasons, which is a nice
accident of design paying off years later.
I wrote last year that what keeps me returning to Go is that the things it is strict about are the things that actually break. This is the same instinct pointed at its own history: the loop variable was a known wart for a decade, and the fix waited until there was a way to ship it that did not surprise anybody. That is a slower kind of correctness than most ecosystems have the patience for.
Tagged
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


