Go

Go for Loops: Range Semantics and Loop Variable Capture Before and After Go 1.22

Learn Go for-loop forms, loop variable capture, Go 1.22 per-iteration scope, module boundaries, and the workaround for older modules.

By Niko Minadze5 min read

Editorial illustration for Go for Loops: Range Semantics and Loop Variable Capture Before and After Go 1.22

In packages contained in modules whose go.mod declares go 1.22 or later, loop variables declared by a for loop have per-iteration scope. Packages in modules with an earlier go directive retain the previous per-loop behavior. That boundary matters whenever a range loop starts goroutines or creates closures: under the old semantics, every function can capture the same variable rather than the value from its own iteration.

The forms of for

for is Go’s only looping construct, but it covers several useful forms.

A classic three-component loop has an initializer, condition, and after statement. The variable declared by the initializer is limited to the loop’s scope:

for i := 0; i < 3; i++ {
    fmt.Println(i)
}

A single-condition loop separates initialization and updates:

i := 0
for i < 3 {
    fmt.Println(i)
    i++
}

A loop without a condition continues until code exits it with break or returns from the enclosing function:

for {
    if workComplete() {
        break
    }
    doWork()
}

Range loops traverse values such as the elements of a slice:

letters := []string{"a", "b", "c"}
for _, letter := range letters {
    fmt.Println(letter)
}

The referenced tutorial also demonstrates ranging over an integer to repeat work a fixed number of times:

for i := range 3 {
    fmt.Println(i)
}

The tutorial source does not state a version boundary for that syntax, so its availability should not be confused with the separate go.mod boundary governing loop-variable scope.

The capture pitfall without goroutines

Concurrency is not required to trigger the old loop-variable problem. Consider a range loop that saves closures and invokes them only after the loop has finished:

package main

import "fmt"

func main() {
    letters := []string{"a", "b", "c"}
    var printLetter []func()

    for _, letter := range letters {
        printLetter = append(printLetter, func() {
            fmt.Println(letter)
        })
    }

    for _, print := range printLetter {
        print()
    }
}

With per-loop scope, the closures all refer to the same letter variable. They are not snapshots of its value when each closure was created. By the time the closures run, the loop has finished and that shared variable contains its final loop value, so this example exhibits the repeated-final-value problem.

With per-iteration scope, each pass creates a distinct loop variable. Each closure therefore captures the variable belonging to its own iteration and observes the corresponding letter.

The same problem with goroutines

The mistake is particularly easy to notice when a range loop launches concurrent work:

letters := []string{"a", "b", "c"}

for _, letter := range letters {
    go func() {
        fmt.Println(letter)
    }()
}

This fragment assumes the surrounding program already waits for the launched work to finish. Under the old per-loop semantics, all three goroutines capture the same letter. The Go loop-variable preview says they usually print c, c, and c, rather than a, b, and c in some order.

Under per-iteration semantics, each goroutine captures its iteration’s distinct variable. The expected values are therefore a, b, and c; their order is not fixed by this example.

What changed at the Go 1.22 module boundary

The Go loop-variable preview describes the change from per-loop scope to per-iteration scope. Go 1.21 included a preview, while the documented Go 1.22 plan limited the new semantics to packages contained in modules declaring go 1.22 or later.

The relevant go.mod directive is:

1.22

In an actual go.mod file, that is the version in the go directive:

go 1.22

The package’s containing module is the key. Merely reading or rewriting the source as if it had the new behavior is not enough; packages in modules with an earlier directive retain the old semantics for backward compatibility.

The workaround for an older module

If a module cannot move to the new boundary yet, create a new variable inside the loop body before constructing the closure or starting the goroutine:

letters := []string{"a", "b", "c"}

for _, letter := range letters {
    letter := letter

    go func() {
        fmt.Println(letter)
    }()
}

The inner declaration gives that iteration’s closure a separate variable to capture. The same pattern works for the non-concurrent closure example:

for _, letter := range letters {
    letter := letter

    printLetter = append(printLetter, func() {
        fmt.Println(letter)
    })
}

This explicit copy also makes the intended capture visible to readers maintaining code that must remain under the earlier module semantics.

Check the behavior in your code

To evaluate an upgrade, place the closure or goroutine example in a module with an earlier go directive and observe the shared-variable behavior. Then set the module’s directive to go 1.22 or later and compare the values captured by each iteration.

For the closure example, the distinction is deterministic in structure: old semantics make every closure share one loop variable, while the new semantics give every iteration its own. For the goroutine example, compare the set of values rather than their order. The source describes the old result as usually three copies of c; with per-iteration variables, the three goroutines observe a, b, and c in some order.

The practical rule is short: check the containing module’s go directive. If it is earlier than 1.22, copy the loop variable inside the body before capturing it. If it is 1.22 or later, loop-declared variables use per-iteration scope.

TaggedGo

Comments

No comments yet.

Leave a comment

Your comment appears after approval. Plain text only; links stay as text. Line breaks and indentation are kept.

Up to 5,000 characters. No account or email needed.

All articles

Type at least two characters.

Press <kbd>Esc</kbd> to closeOpen the search page