skip to content

A module with `go 1.21` in go.mod is built by Go 1.25 — does `for i := 0; i < 3; i++ { go func() { fmt.Println(i) }() }` share one `i`?

level: middleimportance: should knowfreq 42%

answer

  1. two version numbers, only one decides
  2. the language version is per module
  3. 1.22 introduced per-iteration loop variables
  4. gated on go.mod, not on the compiler
  5. flip it with a one-line go.mod edit

basics

~20 s

Yes, one shared i. Per-iteration loop variables arrived in Go 1.22 and are gated on the module's go line, not on the installed release. A go 1.21 line keeps Go 1.21 semantics, so all three goroutines close over the same variable.

solid answer

~40 s

The installed release does not decide this — the go.mod `go` line does. Per-iteration loop variables landed in Go 1.22, and like every language change they are gated on the module's language version, so a module declaring `go 1.21` keeps the older rule where the three-clause `for` has a single `i` for the whole loop. All three goroutines therefore observe the same variable, and what they print depends on scheduling. Building with Go 1.25 changes nothing about that. The switch is `go 1.22` (or later) in go.mod, which is exactly the design intent: a semantic change this quiet must never arrive just because someone upgraded their compiler. Verify by editing the go line and rebuilding, not by changing the installed release.

code

mod · 4 lines
mod
module example.com/lib

// Was: go 1.21 - a single i shared by every iteration.
go 1.22

go deeper

for a junior

Remember there are two version numbers in play — the release you installed and the go line in go.mod — and that the second one decides which language rules apply.

for a middle

Explain the gating mechanism: language changes attach to the module's language version, so the same source compiles differently under different go lines while the compiler stays the same.

for a senior

Show why the design matters operationally: it keeps a toolchain rollout from becoming a semantic migration, and it makes the migration a reviewable commit you can revert on its own.

for a principal

Be ready to argue when your organisation moves its modules' go lines past 1.22, how you sequence that against the toolchain rollout, and what evidence you require before flipping a module with heavy goroutine use.

## Why a compiler upgrade did not change this program The question puts two versions on the table and they do different jobs: - **Go 1.25** is the release performing the build. Its only requirement is to be at least as new as the module's go line. - **`go 1.21`** in go.mod is the module's **language version**. It decides which language rules the compiler applies to these files. Per-iteration loop variables are a **language** change, introduced in **Go 1.22**. Like every language change since modules gained a language version, it is gated on the go line. So a module that declares `go 1.21` is compiled under Go 1.21 rules: the three-clause `for` statement declares one `i` that the whole loop reuses, every function literal closes over that one variable, and building with a Go 1.25 or Go 1.27 install does not alter that by one bit. ### Why the gate exists at all This is the clearest example in the language of why per-module gating was worth building. Changing what a loop variable *is* changes the meaning of programs that already compile and already pass their tests, silently and without any error message. If that change had arrived with the compiler, every team's next toolchain upgrade would have been a semantic migration of all their code at once, with no diff to review. Gating it on the go line inverts that. The compiler upgrade is boring and reversible; the semantic change happens in a one-line commit to go.mod that a reviewer can see, that lands on a date the team chose, and that can be reverted independently of which compiler the CI image ships. ### What actually flips it One edit: ``` go 1.22 ``` After that, the module's files are compiled under Go 1.22 rules and each iteration gets its own `i`. Nothing else — not the installed release, not a build flag you should be reaching for, not clearing the build cache — changes the answer. `go mod edit -go=1.22` performs the edit for you. Because the language version is per module, the effect is neatly scoped: raising your go line changes your module's loops, and leaves every dependency compiling under its own recorded go line. A `go 1.19` library in your build list keeps 1.19 loop semantics inside its own files even while your module runs at 1.22. ### What the goroutines actually print Do not assert an output. With a single shared `i` the three goroutines all read the same variable, and whether they observe 0, 1, 2 or 3 depends entirely on when the goroutine scheduler runs them relative to the loop; concurrent reads of a variable the loop is still writing are also a data race, which `go build -race` will report. The point of the question is not the printed digits — it is which of the two version numbers on the table controls the semantics. ### How to check the version a module is really compiled at Read go.mod. That is the answer for the main module, and `go list -m -f '{{.Path}} {{.GoVersion}}' all` prints the recorded go line for every module in the build list when you want to reason about a dependency's files instead of your own. The Go release you happen to have installed is reported separately by `go version`, and comparing the two is exactly the muscle this question is testing.

  • Why was this change gated on the go line instead of shipping with the compiler?
    Because it silently changes the meaning of code that already compiles. Tying it to the go line keeps compiler upgrades semantically neutral and turns the migration into a reviewable one-line commit that the team schedules, rather than something that arrives with a new build image.
  • If the main module declares `go 1.22`, do its dependencies' loops also get per-iteration variables?
    No. The language version is per module, so each dependency's files are compiled under the go line recorded in its own go.mod. A dependency declaring `go 1.19` keeps the older loop rule inside its own package even in a build driven by a `go 1.22` main module.
  • How would you prove which language version a module is being compiled at?
    Read the go directive in that module's go.mod — for dependencies, `go list -m -f '{{.Path}} {{.GoVersion}}' all` prints it for every module in the build list. `go version` reports the installed release, which is a different question, and the two are frequently different by several releases.

The compiler is the printing press; the go line is the style guide the manuscript was written against. Buying a newer press does not re-edit the manuscript.

saying these in an interview costs you the question

  • Says installing Go 1.22 or newer is enough to change the semantics
  • Claims the program is guaranteed to print 3 3 3
  • Thinks a build flag or cleared cache switches loop semantics
  • Assumes dependencies inherit the main module's language version
  • Believes the compiler decides the language version, not go.mod