go

Go changed a loop, and nothing broke

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.

Deyan Peev

Written by

Deyan Peev

Founding Engineer · Sofia, Bulgaria

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

Elsewhere

© 2026 Deyan Peev