Skip to content
gohacks.dev
Basics

For Range in Go Over Slices Maps Strings and Channels

What for range yields for each type, when the range expression is evaluated, why editing the value does nothing and what copying large structs costs.

12 min read
Summarize in AI
Flat illustration of an editor window full of code.

The for range loop is the one you will write most often, and it hides a handful of rules that all follow from three facts: the operand is evaluated once, the loop variables are copies, and each kind of operand has its own idea of what an element is. People trip over them one at a time, a slice that did not grow, a modification that did not stick, a loop that is mysteriously slow. This guide takes each kind of operand in turn, then the rules about evaluation and copying, runs every claim and finishes with the performance trap that costs the most. It is part of the Go basics path, it builds on slices, maps and strings, and the newer forms get their own article in range over integers and functions.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64, Apple M1 Max)

What each operand yields

One syntax, six meanings. The table is the one to keep nearby, and the program after it runs each row:

Range overFirst valueSecond valueNotes
Slice or arrayIndexA copy of the elementEvaluated once; an array is copied first
Pointer to arrayIndexA copy of the elementThe array is not copied
StringByte offsetA runeDecodes UTF-8; bad bytes give U+FFFD
MapKeyA copy of the valueRandom order
ChannelThe received valueNoneRuns until the channel is closed
Integer n0 up to n-1NoneGo 1.22 and later
Function (iterator)What the function yieldsOptionally a second valueGo 1.23 and later
main.goGo
package main

import "fmt"

func main() {
	for i, v := range []string{"a", "b"} {
		fmt.Println(i, v)
	}

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

	for i, r := range "héy" {
		fmt.Printf("%d:%c ", i, r)
	}
	fmt.Println()

	ch := make(chan int, 3)
	ch <- 1
	ch <- 2
	ch <- 3
	close(ch)
	for v := range ch {
		fmt.Print(v, " ")
	}
	fmt.Println()

	m := map[string]int{"x": 1}
	for k, v := range m {
		fmt.Println(k, v)
	}

	for range 2 {
		fmt.Print("tick ")
	}
	fmt.Println()
}
Text
0 a
1 b
0 1 2 
0:h 1:é 3:y 
1 2 3 
x 1
tick tick

Notice the offsets for the string: h at 0, é at 1 and y at 3, because é takes two bytes, which is the rule from strings as bytes. The bare for range 2 at the end uses neither variable, and since Go 1.22 that is how you repeat something a fixed number of times. You can omit either variable with the blank identifier, for i := range s for the index only and for _, v := range s for the value only. When you need only the index, the shorter form is the better one, since it also avoids the copy of the element.

The operand is evaluated once

Go computes the range expression before the first iteration and does not look at it again. For a slice that means the length is fixed at the start. Appending inside the loop changes the variable and not the iteration:

main.goGo
package main

import "fmt"

func main() {
	s := []int{1, 2, 3}
	for i, v := range s {
		s = append(s, v*10)
		fmt.Print(i, " ")
	}
	fmt.Println(len(s), s)

	arr := [3]int{1, 2, 3}
	for i, v := range arr {
		if i == 0 {
			arr[2] = 100
		}
		fmt.Print(v, " ")
	}
	fmt.Println(arr)

	sl := []int{1, 2, 3}
	for i, v := range sl {
		if i == 0 {
			sl[2] = 100
		}
		fmt.Print(v, " ")
	}
	fmt.Println(sl)
}
Text
0 1 2 6 [1 2 3 10 20 30]
1 2 3 [1 2 100]
1 2 100 [1 2 100]

The first loop ran three times and left the slice with six elements: the new elements were added, and the loop did not see them. The second shows the array rule from arrays in Go. range arr ranges over a copy, so the write to arr[2] was invisible to the loop, which printed 3 and not 100. The third loop ranges over a slice, which shares its array, so the write was visible and the loop printed 100. To iterate an array without copying it, range over &arr or over arr[:].

Maps: random order, and deletion is safe

A map yields its entries in an order that changes from run to run, as measured in why map iteration order is random. Two rules cover modifying a map while ranging. Deleting an entry is safe, and an entry deleted before it is reached will not be produced. Adding an entry is also legal, and the new entry may or may not appear:

main.goGo
package main

import "fmt"

func main() {
	m := map[int]bool{1: true, 2: false, 3: true, 4: false}
	for k, keep := range m {
		if !keep {
			delete(m, k)
		}
	}
	fmt.Println(len(m), m)
}
Text
2 map[1:true 3:true]

The idiom is the standard way to prune a map in place, and the output is stable because the final contents do not depend on the order. If you need to add entries based on what you see, collect them in a second map and merge after the loop. A map loop also costs more per element than a slice loop, since entries are scattered across the table and the runtime has to walk it in a randomized order. If a program iterates a collection far more often than it looks things up, keeping the values in a slice, with a map as an index beside it, is the faster layout.

The value is a copy

For slices, arrays and maps, the value variable holds a copy of the element. Assigning to it changes the copy:

main.goGo
package main

import "fmt"

type Item struct {
	Name  string
	Stock int
}

func main() {
	nums := []int{1, 2, 3}
	for _, n := range nums {
		n *= 2
	}
	fmt.Println(nums)

	for i := range nums {
		nums[i] *= 2
	}
	fmt.Println(nums)

	items := []Item{{"bolt", 10}, {"nut", 4}}
	for _, it := range items {
		it.Stock = 0
	}
	fmt.Println(items)

	for i := range items {
		items[i].Stock = 0
	}
	fmt.Println(items)
}
Text
[1 2 3]
[2 4 6]
[{bolt 10} {nut 4}]
[{bolt 0} {nut 0}]

The first and third loops changed nothing. The second and fourth reached the real elements through the index. If the elements are pointers or contain slices or maps, the copy still shares what those point at, which is the shallow-copy behavior described in the structs guide. Taking &it inside the loop gives you the address of the copy, which since Go 1.22 is a distinct variable per iteration and was the single shared variable before that. The details are in the loop variable change.

The cost of copying large elements

The copy is invisible for ints and strings and expensive for big structs. This benchmark sums a field over 1,000 structs of about 1 KB each, three ways:

main_test.goGo
package main

import "testing"

type Big struct {
	Pad [1024]byte
	N   int
}

var items = make([]Big, 1000)
var sink int

func BenchmarkRangeValue(b *testing.B) {
	for b.Loop() {
		total := 0
		for _, it := range items {
			total += it.N
		}
		sink = total
	}
}

func BenchmarkRangeIndex(b *testing.B) {
	for b.Loop() {
		total := 0
		for i := range items {
			total += items[i].N
		}
		sink = total
	}
}

func BenchmarkRangePointer(b *testing.B) {
	for b.Loop() {
		total := 0
		for i := range items {
			it := &items[i]
			total += it.N
		}
		sink = total
	}
}
Text
goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkRangeValue-10      	   60567	     19829 ns/op	       0 B/op	       0 allocs/op
BenchmarkRangeValue-10      	   60644	     19733 ns/op	       0 B/op	       0 allocs/op
BenchmarkRangeValue-10      	   60184	     19858 ns/op	       0 B/op	       0 allocs/op
BenchmarkRangeIndex-10      	 2788752	       428.3 ns/op	       0 B/op	       0 allocs/op
BenchmarkRangeIndex-10      	 2844836	       432.3 ns/op	       0 B/op	       0 allocs/op
BenchmarkRangeIndex-10      	 2724830	       458.6 ns/op	       0 B/op	       0 allocs/op
BenchmarkRangePointer-10    	 3201871	       384.9 ns/op	       0 B/op	       0 allocs/op
BenchmarkRangePointer-10    	 3147021	       381.0 ns/op	       0 B/op	       0 allocs/op
BenchmarkRangePointer-10    	 3236413	       375.0 ns/op	       0 B/op	       0 allocs/op
PASS
ok  	example.test/lab	11.174s

Ranging by value took about 19,700 ns per pass, indexing took about 425 ns, and taking a pointer to each element about 360 ns. That is a factor of roughly 46 between the slowest and the fastest for the same loop. The copy of 1 KB per element is the entire difference. For small structs of a few words the compiler often removes the copy and the three forms cost the same, so this is not a rule to follow everywhere. It is a rule for slices of large structs in hot code: index or take a pointer. If you profile a loop that looks innocent and see time in memmove, this is a likely cause.

Channels and the loop that ends

for v := range ch receives until the channel is closed and drained, then ends. It is the idiomatic consumer loop. The producer must close the channel, or the consumer blocks forever. A channel that is never closed, ranged by a goroutine, is a goroutine leak waiting to happen, which is why goroutine leaks and how to find them lists it first. Do not close a channel from the receiving side. Closing channels and who is allowed to do it gives the rules.

Integers and functions

Since Go 1.22 you can range over an integer, which replaces the three-clause loop whenever you only count. Since Go 1.23 you can range over a function that follows the iterator shape, which is how the standard library lets you loop over the keys of a map, the lines of a file or the results of a query without building a slice first. Both are covered with examples in range over integers and functions. The one point to remember here is that break and continue work in all of these forms, and a labeled break leaves an outer loop, as described in labels, break and continue.

Nil operands and empty loops

Ranging over a nil slice or a nil map is legal and runs zero iterations, which is why code rarely needs a nil check before a loop. Two operands behave differently. A nil channel blocks forever on receive, so for range over one never finishes and never panics, which is how a goroutine leaks silently when a struct's channel field was never made. And a pointer to an array ranges as the array does, while a nil pointer to an array panics only if the loop asks for a value, since the index-only form for i := range ptr needs just the length, a compile-time constant.

Patterns that come up daily

A few loops are common enough to have standard shapes. Go 1.23 added iterator helpers to slices and maps, and they make several of them one-liners:

main.goGo
package main

import (
	"fmt"
	"maps"
	"slices"
)

func main() {
	nums := []int{10, 20, 30, 40, 50}

	for i := len(nums) - 1; i >= 0; i-- {
		fmt.Print(nums[i], " ")
	}
	fmt.Println()

	for i, v := range slices.Backward(nums) {
		fmt.Print(i, ":", v, " ")
	}
	fmt.Println()

	for chunk := range slices.Chunk(nums, 2) {
		fmt.Print(chunk, " ")
	}
	fmt.Println()

	ages := map[string]int{"ada": 36, "grace": 45, "linus": 28}
	for _, name := range slices.Sorted(maps.Keys(ages)) {
		fmt.Print(name, "=", ages[name], " ")
	}
	fmt.Println()

	for i, v := range slices.All([]string{"a", "b"}) {
		fmt.Print(i, v, " ")
	}
	fmt.Println()
}
Text
50 40 30 20 10 
4:50 3:40 2:30 1:20 0:10 
[10 20] [30 40] [50] 
ada=36 grace=45 linus=28 
0a 1b

Reverse iteration used to need the counting loop in the first block and still does when you want to skip or revisit. slices.Backward yields the same index and value pairs from the end, which reads better and cannot be off by one. slices.Chunk splits a slice into pieces of at most the given size, the standard batching loop for sending rows to a database or messages to an API, and the last chunk may be shorter. The sorted-keys loop appeared in the maps articles because it is the answer to "I need a stable order", and slices.All is the explicit iterator form of an ordinary range, which matters when you pass it to a function that expects an iterator. All of these need a go line of 1.23 or later in go.mod.

Leaving nested loops

A break leaves the innermost loop only. To leave an outer one from inside an inner one, give the outer loop a label:

main.goGo
package main

import "fmt"

func main() {
	grid := [][]int{{1, 2, 3}, {4, 5, 6}, {7, 8, 9}}
	target := 5

outer:
	for r, row := range grid {
		for c, v := range row {
			if v == target {
				fmt.Println("found at", r, c)
				break outer
			}
		}
	}

	for _, row := range grid {
		for _, v := range row {
			if v%2 == 0 {
				continue
			}
			fmt.Print(v, " ")
		}
	}
	fmt.Println()
}
Text
found at 1 1
1 3 5 7 9

The labeled break outer stopped both loops at the first match. The plain continue in the second pair skipped even numbers and moved to the next value of the inner loop. Labels, break and continue in nested loops covers the label rules and when a function with a return is a cleaner way out.

Ranging with goroutines

A loop that starts one goroutine per element is the usual first concurrent program, and under Go 1.22 semantics it needs no workaround. Here the loop splits work across goroutines and gathers results through a channel, which the receiver ranges over:

main.goGo
package main

import (
	"fmt"
	"slices"
	"sync"
)

func main() {
	words := []string{"alpha", "beta", "gamma", "delta"}
	lengths := make(chan int)

	var wg sync.WaitGroup
	for _, w := range words {
		wg.Add(1)
		go func() {
			defer wg.Done()
			lengths <- len(w)
		}()
	}

	go func() {
		wg.Wait()
		close(lengths)
	}()

	var got []int
	for n := range lengths {
		got = append(got, n)
	}
	slices.Sort(got)
	fmt.Println(got)
}
Text
[4 5 5 5]

Each goroutine captures its own w. The extra goroutine that waits and then closes the channel is the standard way to end the receiving range: only after every sender is done is it safe to close. The results arrive in any order, so they are sorted before printing. If you start thousands of goroutines this way, bound the concurrency, as bounded parallelism with errgroup shows.

Copies of rows: slices of slices against arrays of arrays

The "the value is a copy" rule has an exception in effect when the element itself is a slice. The copy is of the slice header, so it still points at the same row, and editing through it changes the original. An array element is copied in full:

main.goGo
package main

import "fmt"

func main() {
	rows := [][]int{{1, 2}, {3, 4}}
	for _, row := range rows {
		row[0] = 0
	}
	fmt.Println(rows)

	cells := [2][2]int{{1, 2}, {3, 4}}
	for _, row := range cells {
		row[0] = 0
	}
	fmt.Println(cells)
}
Text
[[0 2] [0 4]]
[[1 2] [3 4]]

The first loop zeroed the first element of each row of the slice of slices, because row shared the underlying array with rows[i]. The second loop changed nothing in the array of arrays, because each row was a full copy of a [2]int. Both loops look identical and the types decide the result. When you work with grids and matrices, remember that a [][]T element is a view and a [N][M]T element is a value, and use the index form when you want your edit to land in the array whatever the type.

Mistakes worth recognizing on sight

What you seeWhat is wrongFix
for _, x := range s { x.Count++ }Edits a copyfor i := range s { s[i].Count++ }
for _, v := range bigStructs in hot codeCopies each elementIndex, or take &s[i]
for i, v := range s { s = append(s, ...) } expecting more passesThe range ran over the old lengthUse an index loop that rechecks len(s)
for k := range m assuming sorted orderOrder is randomSort the keys first
for v := range ch with no closeBlocks foreverClose from the sender when done
go func() { use(v) }() in a loop on go 1.21Shared loop variableRaise the go line or copy the variable

When a plain index loop is better

Use for i := 0; i < len(s); i++ when you need to move the index yourself, skipping ahead by more than one, walking backwards, or revisiting an element. Use it when the loop body appends to the slice and must process the new elements, such as a work queue. And reach for range everywhere else, because it expresses "each element" without off-by-one risk. The loop form you choose also affects bounds checks: the compiler removes them in a range loop, and a hand-written index loop may keep them, though the cost is small and measurable only in the hottest code. The specification section on for statements with range clauses lists each case above, and the Go blog's article on the 1.22 loop change explains why the variables are per-iteration.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.