Iterating with range

The range keyword is Go’s built-in way to walk through the elements of a slice, array, map, string, or channel without hand-writing index bookkeeping. Instead of a C-style for i := 0; i < len(s); i++ loop, you write for i, v := range s and Go hands you the index (or key) and value on every pass. It is one of the most heavily used constructs in idiomatic Go, and it behaves subtly differently depending on what you range over — understanding exactly what it copies, when it stops, and how each collection type is handled will save you from some of the language’s most common bugs.

Overview: How range Works

range is not a function or a method — it is a keyword built directly into the for statement’s syntax. The compiler generates different code depending on the type of the expression after range:

Arrays and slices. Ranging produces an index (int) and a copy of the element at that index. Remember that a slice is a small header value containing a pointer to an underlying array, a length, and a capacity. When you range over a slice, Go reads that header once at the start, so the loop runs for the length the slice had when the loop began — appending to the slice inside the loop does not extend how many iterations happen. The value you receive on each iteration (v in for i, v := range s) is a copy of the element, not a reference to it, so mutating v never changes the original slice.

Maps. Ranging produces a key and a value. Map iteration order is deliberately randomized by the Go runtime on every run — this is intentional, to stop programmers from accidentally depending on an order that was never guaranteed. If you need a predictable order, collect the keys, sort them, and then look up each value.

Strings. Ranging over a string does not give you bytes one at a time. Go strings are UTF-8 encoded byte sequences, and range decodes the string one Unicode code point (rune) at a time. The first return value is the byte offset where that rune starts (not a sequential 0, 1, 2, … counter), and the second is the decoded rune (an int32). Because some runes take more than one byte in UTF-8, the byte offsets can jump by more than one between iterations.

Channels. Ranging over a channel receives values from it repeatedly, blocking whenever the channel is empty, until the channel is closed and drained — at which point the loop exits automatically. This is the idiomatic way to consume everything a channel will ever send without checking the second boolean return value of a manual receive yourself.

Integers (Go 1.22+). Modern Go also lets you write for i := range n to loop i from 0 to n-1, as a concise replacement for the classic counting loop. It requires Go 1.22 or newer, so the classic three-part for loop remains the more portable choice if you need to support older toolchains.

Syntax

for index, value := range collection {
    // use index and value
}
Form What you get
for i, v := range s index/key i and a copy of the value v
for i := range s only the index or key, value discarded
for _, v := range s only the value, index discarded
for range s neither — just loop once per element, useful for counting or draining
for v := range ch single variable form used for channels: the received value

The collection type determines what index and value mean: on a slice or array it is (int, T); on a map it is (K, V); on a string it is (int byteOffset, rune); on a channel it is a single received value.

Examples

Example 1: Ranging over a slice

package main

import "fmt"

func main() {
	fruits := []string{"apple", "banana", "cherry"}
	for i, fruit := range fruits {
		fmt.Println(i, fruit)
	}
}

Output:

0 apple
1 banana
2 cherry

Each iteration gives the zero-based index and a copy of the string stored at that index. Strings in Go are cheap to copy (they are just a pointer and a length internally), so this copy is not a performance concern; the same is not always true for large structs, which is why ranging over a slice of large structs by index and accessing s[i] directly is sometimes preferred.

Example 2: Ranging over a map (with deterministic order)

package main

import (
	"fmt"
	"sort"
)

func main() {
	scores := map[string]int{"alice": 90, "bob": 85, "carol": 95}

	keys := make([]string, 0, len(scores))
	for k := range scores {
		keys = append(keys, k)
	}
	sort.Strings(keys)

	for _, k := range keys {
		fmt.Println(k, scores[k])
	}
}

Output:

alice 90
bob 85
carol 95

Ranging directly over scores would visit the same three pairs, but in a different, randomized order on every run of the program. Here we first range over the map just to collect its keys into a slice, sort that slice, and then range over the sorted slice to print in a stable, predictable order.

Example 3: Ranging over a string (bytes vs runes)

package main

import "fmt"

func main() {
	word := "h\u00e9llo"
	for i, r := range word {
		fmt.Printf("index %d: %c (%d)\n", i, r, r)
	}
}

Output:

index 0: h (104)
index 1: é (233)
index 3: l (108)
index 4: l (108)
index 5: o (111)

The word contains an accented é, which UTF-8 encodes using two bytes. Notice the index jumps from 1 to 3: the é rune occupies byte offsets 1 and 2, so the next rune (l) starts at offset 3. If you had instead written for i := 0; i < len(word); i++ and indexed word[i], you would get individual bytes, not characters, and would corrupt any multi-byte rune. This is exactly why range is the correct way to walk a string character by character.

Example 4: Ranging over a channel

package main

import "fmt"

func main() {
	ch := make(chan int, 3)
	ch <- 1
	ch <- 2
	ch <- 3
	close(ch)

	for v := range ch {
		fmt.Println(v)
	}
}

Output:

1
2
3

The loop receives values in the order they were sent (channels are FIFO) and exits automatically once the channel is closed and empty. If the channel were never closed, this loop would block forever waiting for one more value after the buffer was drained — always ensure a channel you range over is eventually closed by its sender.

How It Works Step by Step

For for i, v := range s on a slice s, the compiler roughly desugars the loop to something like: evaluate s once and remember its length; then, for each step from 0 up to that remembered length, set i to the step number, copy s[i] into v, and run the loop body. Because the length is captured once, up front, changes to the slice's length during the loop (via append on the original variable, for example) do not change how many times the loop runs. For maps, the runtime instead walks its internal hash buckets in an order that is randomized at the start of each range, visiting every key/value pair exactly once. For channels, each iteration performs a blocking receive; if the channel is empty the goroutine running the loop parks until a value arrives or the channel is closed, and the loop exits the instant a receive reports the channel is closed with no more buffered values.

Common Mistakes

Mistake 1: Expecting range to mutate the original elements

The value returned by range is a copy. Modifying it modifies only the local copy, not the underlying slice.

// Wrong: n is a copy, so this has no effect on nums
nums := []int{1, 2, 3}
for _, n := range nums {
	n *= 10
}
fmt.Println(nums) // still [1 2 3]

To mutate the underlying elements, index back into the slice using the index range gives you:

package main

import "fmt"

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

Output:

[10 20 30]

Mistake 2: Capturing the loop variable in a goroutine closure

Before Go 1.22, all iterations of a for loop shared the same underlying loop variable. A closure created inside the loop body that referenced the loop variable directly — instead of taking it as a parameter — would see whatever value the variable held by the time the goroutine actually ran, not the value at the time the goroutine was launched:

// Bug on Go < 1.22: i and item are shared across all goroutines,
// so by the time the goroutines run, the loop may have already
// advanced. Output is unpredictable and often repeats one value.
items := []string{"a", "b", "c"}
var wg sync.WaitGroup
for i, item := range items {
	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println(i, item)
	}()
}
wg.Wait()

Go 1.22 changed loop semantics so each iteration gets its own copy of the loop variables, which fixes this specific case going forward. Even so, the defensive, version-portable fix is to pass the values in explicitly as parameters to the goroutine's function literal, which has always worked correctly:

package main

import (
	"fmt"
	"sync"
)

func main() {
	items := []string{"a", "b", "c"}
	results := make([]string, len(items))
	var wg sync.WaitGroup

	for i, item := range items {
		wg.Add(1)
		go func(i int, item string) {
			defer wg.Done()
			results[i] = fmt.Sprintf("processed %d:%s", i, item)
		}(i, item)
	}

	wg.Wait()
	for _, r := range results {
		fmt.Println(r)
	}
}

Output:

processed 0:a
processed 1:b
processed 2:c

Each goroutine here writes to its own, distinct slice index, so there is no data race even though the goroutines run concurrently, and printing happens only after wg.Wait() returns, so the final order is always index order.

Mistake 3: Assuming map range order is stable or sorted

Code that ranges over a map and relies on the order of keys — for example, printing a report and expecting alphabetical order, or building a slice and expecting insertion order — will appear to work during quick manual testing and then fail unpredictably, because the Go runtime deliberately randomizes map iteration order on every run. As shown in Example 2, if order matters, collect the keys into a slice, sort that slice, and range over the sorted slice instead of the map directly.

Best Practices

  • Use for _, v := range s when you only need values, and for i := range s when you only need indices or keys — the blank identifier makes intent explicit and avoids an unused-variable error.
  • Never rely on map iteration order; sort keys explicitly whenever a stable order is required.
  • Remember that the loop variable is a copy: to mutate elements in place, index into the original slice with s[i] rather than modifying the range value.
  • When ranging over a channel, make sure something eventually calls close on it, or the loop will block forever once the channel is drained.
  • When passing loop variables into goroutines, pass them as explicit function parameters rather than capturing them from the enclosing scope — it is correct on every Go version and makes the intent obvious to readers.
  • Use range over a string when you need to process actual characters (runes); index a string directly only when you deliberately want raw bytes.
  • Prefer for range n (Go 1.22+) for simple counting loops when you know your build targets a modern toolchain; otherwise stick with the classic three-part for loop for portability.

Practice Exercises

  • Write a program that ranges over a slice of integers and prints the sum of all values greater than 10.
  • Write a program that ranges over the string "Go\u00f6d" and prints each rune's byte offset alongside the rune itself, then explain in a comment why one offset is skipped.
  • Write a program that builds a map[string]int of word counts from a slice of words, then ranges over the map's sorted keys to print the counts in alphabetical order. Expected behavior: running the program twice should always print the words in the same order.

Summary

  • range iterates over slices, arrays, maps, strings, and channels, producing a different pair of values for each collection type.
  • On slices and arrays it gives an index and a copy of the element; mutate through s[i] to change the original.
  • On maps it gives a key and value in randomized order; sort keys yourself for deterministic output.
  • On strings it decodes UTF-8 runes, giving the byte offset (not a sequential counter) and the decoded rune.
  • On channels it receives values until the channel is closed and drained, then exits automatically.
  • Before Go 1.22, loop variables were shared across iterations, causing a classic goroutine-capture bug; pass loop variables as explicit parameters to closures to stay safe on every version.
  • Go 1.22+ adds for i := range n for simple integer counting loops.