JavaRush /Courses /Go SELF /Closures and loop variables in Go: range pitfalls after G...

Closures and loop variables in Go: range pitfalls after Go 1.22+

Go SELF
Level 65, Lesson 4
Available

1. What exactly does a closure capture in Go: a variable or a value

If you are just starting to write concurrent code, it is very easy to end up in a situation like “I did everything right… but it sometimes prints the wrong thing”. And that is where the “magic” begins, which is not really magic at all, but a combination of three things: closures capture variables, a loop reuses variables, and goroutine run in an unpredictable order. In Go 1.22+ some of the pain was removed, but not all of it.

Imagine the loop as an assembly line, and goroutine as couriers running off with a note. Historically, the problem was that you did not copy the text of the note, but handed the courier a reference to the same sheet of paper. The loop had time to rewrite the sheet, and all couriers would deliver the “latest version”. In Go 1.22+ for typical loops the assembly line started handing out a new sheet on each iteration (yay), but if you forcibly reuse one sheet yourself, you bring the old problem back.

Closures are often explained as “a function with memory”. And that is almost true, but one detail matters: the closure “remembers” not the value, but the variable (that is, the place where the value lives). So if someone changes that variable later, the closure will see the new value. While everything is single-threaded, you will just be surprised. If there is a goroutine involved, you will be surprised louder.

Let’s start with a simple example without goroutine — to see the mechanics in a calm environment:

package main

import "fmt"

func main() {
	x := 10

	f := func() {
		fmt.Println("x =", x)
	}

	x = 20
	f() // x = 20
}

Here the closure f uses the variable x from the outer scope. We changed x, and f() honestly saw the new value.

Now let’s lock this in with a small table — this is more important than it looks:

Wording In plain English In practice in code
“A closure captures a value” as if it were a copy of a number/string no, not a copy
“A closure captures a variable” a reference to the place where the value lives yes, it sees changes

And that is why loop + closure is an explosive mix: the loop usually works with one variable, which changes from iteration to iteration.

3. The classic range + goroutine trap before Go 1.22

Even if you write Go 1.25, it is useful to understand what problem was actually fixed. Otherwise it is easy to draw the wrong conclusion: “in Go it is now always safe to start a goroutine in a loop”. That is not true.

Classic example (it used to be able to print the same value multiple times):

package main

import (
	"fmt"
	"sync"
)

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

	var wg sync.WaitGroup
	wg.Add(len(values))

	for _, v := range values {
		go func() {
			defer wg.Done()
			fmt.Println(v)
		}()
	}

	wg.Wait()
}

Before Go 1.22 the variable v was a single variable for the whole range loop; only its value changed on each iteration. The closure inside the goroutine “held on” to the variable v, and therefore could observe a value different from what you intuitively expected.

Starting with Go 1.22 the “long-lived loop variable trap” was addressed: iteration variables are no longer “accidentally shared” between iterations in the way that broke closures. The Go 1.22 release notes explicitly say that this gotcha is now fixed, and show an example that prints "a", "b", "c" in some order.

Important: even when this is fixed, the order remains non-deterministic. That is, “in some order” is a normal result.

4. What changed in Go 1.22+ and why the “typical case” now works

With go 1.22 or higher in go.mod (ours is go 1.25), variables declared in the loop header are created anew on each iteration. What matters is the go directive in go.mod, not the installed Go version: a module with go 1.21 built with Go 1.25 follows the old rules. Thanks to this, typical patterns like range + go func(){...}() stopped being a time bomb.

But it is very easy to make the wrong logical leap here: “so I can stop thinking altogether”. You cannot. The fix applies to the typical case, where the iteration variable is declared inside the loop header (for _, v := range ..., for i := 0; ...; ...). As soon as you start “manually turning the knob” on the variable (declare it outside and assign inside), you return to the old model.

You can draw it as a diagram:

flowchart TD
    A[Loop] --> B{Is the iteration variable declared in the header?}
    B -- yes --> C[Go 1.22+ makes iterations safer for closures in the typical case]
    B -- no --> D[One variable remains for all iterations]
    D --> E[Closures and goroutines see the same memory location]

The point of the diagram: the fix is not “magical”; it is about specific syntax and a specific place where the variable is declared.

5. Why passing a parameter into a goroutine is still the best style

When a language fixes an old trap, the temptation is to write “as short as possible”. But in real code, “shorter” often means “less explicit”. And in concurrency, implicitness is like walking around a dark room with LEGO all over the floor: you can, but why.

So even in Go 1.25, good style is to make the goroutine input explicit: pass the value as a parameter. This is not only a way to avoid traps, but also a readability boost: in the goroutine you can see “this is where this variable came from”.

package main

import (
	"fmt"
	"sync"
)

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

	var wg sync.WaitGroup
	wg.Add(len(values))

	for _, v := range values {
		go func(v string) {
			defer wg.Done()
			fmt.Println(v)
		}(v)
	}

	wg.Wait()
}

It is the same idea, but now the dependency of the goroutine on v looks like a normal “function input variable”.

A similar approach appears in classic concurrency examples as well: when you start a goroutine from a loop, it is better to pass the value as a parameter so the closure does not depend on a mutable outer variable.

6. Traps with reusing variables in a loop

The variable is declared outside the loop and reused via assignment

Now to the main point: “the typical case is fixed, but cases with variable reuse remain”. The most common one is when you declare the variable outside, and inside the loop you only assign to it.

And here you are effectively “undoing” the Go 1.22+ improvement, because you again create one variable for the whole loop.

package main

import (
	"fmt"
	"sync"
)

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

	var wg sync.WaitGroup
	wg.Add(len(values))

	var v string // one variable for the whole loop

	for _, s := range values {
		v = s
		go func() {
			defer wg.Done()
			fmt.Println(v) // bad: reading one variable that the loop keeps changing
		}()
	}

	wg.Wait()
}

What is wrong here at two levels at once.

At the logic level: all goroutine read the same variable v, which changes in the loop, so the result will be “luck-based” (often the same value).

At the correctness level of concurrent code: this is also a potential data race, because the loop writes v while the goroutine read v concurrently, without synchronization. And that is no longer just “wrong output” — it is “the program is unpredictable”.

This is fixed the same way: “make the goroutine input explicit”, even if the variable is external:

for _, s := range values {
	v = s
	go func(v string) {
		defer wg.Done()
		fmt.Println(v)
	}(v)
}

It looks a bit funny because we first write into the outer v, and then pass v as a parameter. In real code it is better not to have an outer v at all if it is not needed. But the example shows the main idea: a goroutine must get its own value, not a reference to a location that someone keeps mutating.

The loop index is declared outside the for

Go 1.22+ fixed the typical case where the variable is declared in the for header. But if you declared the index ahead of time, it is again one variable.

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Add(3)

	i := 0
	for ; i < 3; i++ {
		go func() {
			defer wg.Done()
			fmt.Println("i =", i)
		}()
	}

	wg.Wait()
}

If you expect "i = 0", "i = 1", "i = 2" (in any order), that expectation does not have to hold here, because the closure reads the variable i, and the loop continues to change it. And again: this is both a logical bug and a potential data race.

The fix is the same: a parameter.

for ; i < 3; i++ {
	go func(i int) {
		defer wg.Done()
		fmt.Println("i =", i)
	}(i)
}

Here you literally say: “here is the value of i at the moment of launch; use it”.

7. A range nuance: element copy and the variable’s address

There is a separate class of bugs that people often mistakenly call “a closures problem”, when it is actually about range semantics.

When you do for _, t := range tasks, the variable t is a copy of the slice element (if the element is not a pointer). Even if in Go 1.25 t is now “new per iteration”, it is still a copy. Therefore:

  • printing t is fine;
  • taking &t gives you the address of the copy, not the element in the slice;
  • changing t.Done = true changes the copy, not the original.

Let’s show this with an example, without involving concurrency — so it is clear where the catch is:

package main

import "fmt"

type Task struct {
	Title string
	Done  bool
}

func main() {
	tasks := []Task{{Title: "A"}, {Title: "B"}}

	for _, t := range tasks {
		t.Done = true // modify the copy
	}

	fmt.Println(tasks[0].Done, tasks[1].Done) // false false
}

If you want to modify the real slice elements, you usually need the index:

for i := range tasks {
	tasks[i].Done = true
}

And if you really want to work via a pointer, do it like this:

for i := range tasks {
	t := &tasks[i]
	t.Done = true
}

This nuance is worth keeping in mind, because in concurrent code it can look like “the goroutine writes to the wrong place” — while you were simply writing into a copy.

8. Mini example: concurrent task validation

To connect the topic to practice, imagine a small piece of our learning app with a list of tasks. We want to quickly check each task for a simple issue: an empty title. The check is intentionally simple; what matters is the pattern of starting a goroutine in a loop and carefully pinning down the values.

Let’s make a function that returns an error string for a specific task:

package main

import "fmt"

type Task struct {
	ID    int
	Title string
}

func validateTask(t Task) string {
	if t.Title == "" {
		return fmt.Sprintf("task %d: empty title", t.ID)
	}
	return ""
}

Now we want to run validation concurrently and collect results without races: each goroutine writes to its own index, and we read only after wg.Wait() (exactly the “safe minimum” we discussed in the data race lecture).

package main

import "sync"

func validateAll(tasks []Task) []string {
	results := make([]string, len(tasks))

	var wg sync.WaitGroup
	wg.Add(len(tasks))

	for i, t := range tasks {
		go func(i int, t Task) {
			defer wg.Done()
			results[i] = validateTask(t)
		}(i, t)
	}

	wg.Wait()
	return results
}

Notice the style: we passed both i and t as parameters. In Go 1.25 this will often work even without parameters, but with parameters the code becomes “self-documenting”: the goroutine uses exactly the values that were present at that iteration.

And yes, it is also a convenient habit for the future: when channels, select, and more complex schemes appear, an explicit goroutine input almost always makes the code simpler.

9. Common mistakes

Mistake #1: thinking that Go 1.25 “fixed closures forever”.
Go 1.22+ did fix the known iteration-variable trap in the typical case, and this is explicitly reflected in the release notes. But if you declare a variable outside the loop and reuse it via assignment, you again create one shared variable and bring the old problem back.

Mistake #2: “I use WaitGroup, so everything is safe”.
WaitGroup guarantees only that the goroutine will finish. It does not protect variables from concurrent reads/writes. So code where the loop writes to an outer variable and the goroutine read it remains a potential data race — even if you wait for everything via wg.Wait().

Mistake #3: “fixing” behavior with time.Sleep.
Sometimes you want to “wait a bit so the goroutine can finish”. But time.Sleep does not pin variable values and does not make the execution order a contract. It only masks the problem (and makes it more random). To wait for completion — WaitGroup; to pin inputs — parameters or local variables.

Mistake #4: confusing the “closure problem” with the fact that range yields a copy of the element.
When you modify the variable t from for _, t := range tasks, you modify a copy. Even if everything is single-threaded and even if Go 1.25 makes the iteration variable “its own on each iteration”, the original slice elements will not change. To modify elements, use the index (tasks[i]) or take the address of the element (&tasks[i]), not the address of the variable t.

Mistake #5: leaving a goroutine’s dependency on outer variables implicit.
Even if “it works”, code without parameters in a goroutine is harder to read: it is unclear what is pinned and what might change. In concurrency it is better to be boring and explicit: a parameter to an anonymous function is a small price for a lot of peace of mind.

1
Task
Go SELF, level 65, lesson 4
Locked
Squares in Place
Squares in Place
1
Task
Go SELF, level 65, lesson 4
Locked
Job logs
Job logs
1
Task
Go SELF, level 65, lesson 4
Locked
Task pointers
Task pointers
1
Task
Go SELF, level 65, lesson 4
Locked
Parallel validation
Parallel validation
1
Survey/quiz
Concurrency 1: goroutines, 65 level, 4 lesson
Unavailable
Concurrency 1: goroutines
Concurrency 1: goroutines
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION