JavaRush /Courses /Go SELF /Pointer pitfalls in Go: the address of a range variable

Pointer pitfalls in Go: the address of a range variable

Go SELF
Level 19, Lesson 4
Available

1. The main “rake” in for range

When we first learn about & and *, our hands immediately start itching: “Oh! So I can walk a slice and collect pointers to its elements, and then modify them directly ‘in place’.” And that’s a normal desire: pointers are often used precisely to explicitly tell the code, “I want to mutate the original data, not work with a copy.”

The problem is that a for range loop looks simple, almost like natural language. Your brain relaxes. And Go at that moment is like: “Great, now I’ll check how carefully you distinguish ‘a slice element’ from ‘a loop variable’.” And if you miss the mark, you can get very strange effects: changes “don’t apply”, or suddenly all pointers look suspiciously identical.

Mini task: collect pointers to the matched strings

So we’re not discussing pointers in a vacuum, let’s continue the line of the training mini app: we have a task list as a []string. No structs, no classes, no heavy architecture—just a list of strings. We want to find tasks that contain a substring (for example, go), and return pointers to those strings, so that later we can modify them directly in the original list.

It sounds very practical: find matching tasks—and then, for example, add a mark in the text. Yes, we’re not building a full “todo manager” yet, but it’s exactly these small utilitarian chunks of code that later become building blocks of larger programs.

The trap: &v in for _, v := range tasks

Now comes the point where wording matters. In for _, v := range tasks the variable v is a loop variable into which, on each iteration, the element value is assigned. That value may be a copy. And when we take &v, we take the address of that variable, not the address of the array element inside the slice.

A separate nuance that matters specifically for Go 1.25: starting with Go 1.22, the for loop fixed a long-standing “rake” related to loop variables being unexpectedly shared between iterations; now the loop behavior is safer in typical cases. This is noted in the Go 1.22 announcement.
But even with this improvement, the semantic problem remains: &v is a pointer to the loop variable, not to an element of the original slice. That is, you get not “a reference to the task in the list”, but “a reference to a temporary value that was in the loop variable.”

Let’s look at this through debugging eyes.

package main

import (
	"fmt"
)

func main() {
	tasks := []string{"buy milk", "learn go", "sleep"}

	for _, v := range tasks {
		fmt.Printf("v=%q addr=%p\n", v, &v)
	}
}

Here the addresses may look “fine” (they may be different), and that’s exactly why this bug is so insidious: you see addresses and think “great, I definitely took the address of the elements.” But in reality you took addresses of copy variables that live separately from tasks.

To make it completely obvious, let’s run an experiment: collect pointers, change a value through a pointer, and see whether the original slice changed.

package main

import (
	"fmt"
)

func main() {
	tasks := []string{"buy milk", "learn go", "sleep"}

	ptrs := make([]*string, 0, len(tasks))
	for _, v := range tasks {
		ptrs = append(ptrs, &v) // address of the loop variable, not an element of tasks
	}

	*ptrs[1] = "HACKED"
	fmt.Println("via pointer:", *ptrs[1])       // via pointer: HACKED
	fmt.Println("in the original slice:", tasks[1]) // in the original slice: learn go
}

That’s the key signal: if you wanted to mutate tasks, but only what the pointer points to changes—then the pointer is not pointing to an element of tasks.

Second “rake”: “identical pointers” because of a reused variable

Now about the effect that most often looks like a detective story: you print a list of pointers, but for some reason they’re the same, and all values look like “the last ones.” In Go 1.25 the “classic” &v inside for range doesn’t lead to identical addresses as often, but the bug hasn’t gone away as a phenomenon: it’s easy to reproduce with your own hands if, for some reason, you move the variable outside and reuse it.

The most common storyline is: a developer heard somewhere “don’t take the address of a range variable,” and decided to “fix” the code… by making it even worse.

package main

import (
	"fmt"
)

func main() {
	tasks := []string{"buy milk", "learn go", "sleep"}

	var v string // one variable for all iterations
	ptrs := make([]*string, 0, len(tasks))

	for _, item := range tasks {
		v = item
		ptrs = append(ptrs, &v) // the address is the same
	}

	fmt.Printf("%p %p %p\n", ptrs[0], ptrs[1], ptrs[2]) // addresses are identical (usually)
	fmt.Println(*ptrs[0], *ptrs[1], *ptrs[2])           // sleep sleep sleep
}

Why does that happen? Because v is one specific variable, with one address. In the loop you only overwrite its value. As a result, you push the same address into ptrs many times, and by the end of the loop v holds the last assigned value.

This is an example where pointers honestly do what you asked, but you accidentally asked for the wrong thing.

Cheat sheet: what each variant means

To avoid memorizing “magic formulas,” it’s useful to match the goal to the code. This table isn’t about bureaucracy— it’s to reduce mental clutter.

Goal How to write it What you get
Take pointers to real elements of a slice (to mutate the original slice)
for i := range s { ptrs = append(ptrs, &s[i]) }
Pointers to elements of the underlying array
Take pointers to copies (for example, take a “snapshot” of values)
for _, v := range s { x := v; ptrs = append(ptrs, &x) }
Pointers to separate copy variables
Accidentally collect “all pointers are the same”
var x T; for ... { x = ...; ptrs = append(ptrs, &x) }
One address repeated many times, values are “the last ones”

Notice the second variant: sometimes a pointer to a copy is fine if you truly want independent values. It’s just important to understand that they are copies, and changes through them should not “magically” land in the original slice.

Micro-diagnostics: check addresses with %p

When you catch this kind of bug, you want to “immediately understand” what’s happening. Here’s a simple, almost school-level trick: print addresses with %p and check whether they match what you expect.

Imagine you’re unsure whether you’re pointing at slice elements or at copies. Then you can do an “X-ray” like this:

package main

import (
	"fmt"
)

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

	for i := range tasks {
		fmt.Printf("&tasks[%d]=%p value=%q\n", i, &tasks[i], tasks[i])
	}
}

If in your real code the addresses are “for some reason identical,” you’re almost certainly taking the address of the same variable that you’re reusing. If the addresses are different but the changes “don’t reach” the slice, you’re probably taking the address of copies (for example, &v), and then you need to change the approach.

3. The right way: the address of an element by index &tasks[i]

Now let’s do the same thing, but correctly. We’ll iterate by indices and take the address of a real element in the array that underlies the slice. That element is addressable, so &tasks[i] is allowed and gives what we want.

package main

import (
	"fmt"
)

func main() {
	tasks := []string{"buy milk", "learn go", "sleep"}

	ptrs := make([]*string, 0, len(tasks))
	for i := range tasks {
		ptrs = append(ptrs, &tasks[i]) // address of the real element
	}

	*ptrs[1] = "LEARN GO (done)"
	fmt.Println(tasks[1]) // LEARN GO (done)
}

This behavior already looks like “controlled mutation of the original data.”

To reinforce the idea, it helps to keep a picture like this in mind (simplified):

flowchart LR
    tasks["tasks: []string (slice header)"] --> arr["Underlying array (strings)"]
    arr --> e0["tasks[0] = 'buy milk'"]
    arr --> e1["tasks[1] = 'learn go'"]
    arr --> e2["tasks[2] = 'sleep'"]

    p1["p1 = &tasks[1] (*string)"] --> e1
    v["v (loop variable)"] -. "value copy" .-> e1
    pv["pv = &v"] --> v

&tasks[i] points to an element in the underlying array, while &v points to a separate variable v that just received values one by one.

4. Practice: FindTaskPtrs for the mini app

Now let’s assemble a small but complete example you can compile and run. We’ll make a function FindTaskPtrs that searches tasks by substring and returns []*string, i.e., pointers to elements of the original slice.

Here we use strings.Contains because it reads well: a task contains text.

package main

import (
	"fmt"
	"strings"
)

func FindTaskPtrs(tasks []string, query string) []*string {
	found := make([]*string, 0)
	for i := range tasks {
		if strings.Contains(tasks[i], query) {
			found = append(found, &tasks[i])
		}
	}
	return found
}

func main() {
	tasks := []string{"buy milk", "learn go", "sleep", "go to gym"}

	ptrs := FindTaskPtrs(tasks, "go")
	fmt.Println(len(ptrs)) // 2

	*ptrs[0] = strings.ToUpper(*ptrs[0])
	*ptrs[1] = strings.ToUpper(*ptrs[1])

	fmt.Println(tasks) // [buy milk LEARN GO sleep GO TO GYM]
}

A crucial detail: we iterate over indices for i := range tasks, because our goal is element addresses. If we wrote for _, v := range tasks and returned &v, then changes through the pointers would not mutate tasks, and that would be a nasty surprise.

5. Common mistakes

Error #1: take &v in for _, v := range tasks and expect changes to affect tasks.
This mistake looks especially honest: you really got a *string, really dereferenced it, and changed the value. You just changed the loop variable (i.e., a separate value), not an element of the underlying array. The fix is a simple mental switch: if you need an element address— iterate over indices and take &tasks[i].

Error #2: “fix” the &v issue by moving the variable outside, and accidentally get “all pointers are the same.”
This is the case where the code becomes logically worse, even though visually it looks “cleaner”: var v string; for ... { v = item; ptrs = append(ptrs, &v) }. Here you repeatedly store the same address. If you see that all values at the end are identical (usually “the last ones”), this is almost always it. The fix is to not reuse one variable for all iterations, and instead work either with &tasks[i], or with a separate new variable inside the iteration if you need copies.

Error #3: confuse “I want a reference to an element” with “I want a snapshot of a value.”
Sometimes the task truly requires copies: for example, you want to keep the current values to compare them later, even if the original slice changes. In that case, “pointers to copies” are a valid model; it just needs to be a conscious choice. In that situation, it’s better to name the variable something like snapshot := tasks[i], so the reader can see: it’s a separate entity, not an array element.

Error #4: debug by looking only at values and not at addresses.
Values often don’t show anything: strings may match, data is small, everything looks “fine.” But addresses printed with %p instantly show whether you’re pointing at the same object or different ones. This is especially useful when you suspect “identical pointers,” but you’re not sure what’s identical: the value or the address.

Error #5: use pointers “just in case,” then be surprised by aliasing effects.
A pointer is almost always about shared data: now a single change can be visible in multiple places. That’s powerful, but it requires discipline. If you just need to compute a new list of strings, it’s often easier to return a new []string than to hand out []*string. Pointers are worth using when you truly need address-based access to the original data, not as a “universal speed booster” (they are not).

1
Task
Go SELF, level 19, lesson 4
Locked
Phantom edit
Phantom edit
1
Task
Go SELF, level 19, lesson 4
Locked
Three identical
Three identical
1
Task
Go SELF, level 19, lesson 4
Locked
Live pointers
Live pointers
1
Task
Go SELF, level 19, lesson 4
Locked
Snapshot of values
Snapshot of values
1
Survey/quiz
Pointers, 19 level, 4 lesson
Unavailable
Pointers
Pointers, new vs make
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION