How to Use the Range Keyword in Go

The range keyword in Go iterates over collections like slices and maps, returning the index/key and value for each element.

The conveyor belt of Go iteration

You have a slice of user IDs to validate. You have a configuration map to load. You have a live stream of log lines arriving over a channel. In most languages, you reach for a counter, a while loop, or an iterator object. Go gives you one keyword that handles all three: range.

range is the default iteration primitive in Go. It walks over arrays, slices, maps, strings, and channels. It hands you the position and the item at each step, or just the item when the position doesn't matter. You do not manage the loop counter. You do not call .next(). You write for key, value := range collection and the language handles the rest.

How range actually works

Think of range as a factory scanner. You feed it a collection. The scanner reads the first item, hands you the item and its label, then moves to the next. When the collection runs out, the scanner stops. The scanner adapts to the shape of what you feed it. A slice gives you a numeric index. A map gives you a key. A string gives you a byte offset and a Unicode character. A channel gives you values until the pipe closes.

Here is the simplest slice iteration:

// main.go
package main

import "fmt"

func main() {
    // slice holds three integers
    numbers := []int{10, 20, 30}
    
    // range yields index and value on each pass
    for i, v := range numbers {
        // print both pieces of information
        fmt.Printf("Index: %d, Value: %d\n", i, v)
    }
}

The loop runs three times. i takes the values 0, 1, 2. v takes the values 10, 20, 30. The compiler generates the bounds checking and the element fetching. You only write the body.

If you only care about the values, discard the index with an underscore. The underscore is Go's explicit way of saying "I saw this return value and chose to ignore it."

// main.go
package main

import "fmt"

func main() {
    // slice holds three integers
    numbers := []int{10, 20, 30}
    
    // underscore discards the index variable
    for _, v := range numbers {
        // print only the values
        fmt.Println(v)
    }
}

Discarding unused variables is a compiler rule in Go. The compiler rejects programs with unused variables to catch typos early. The underscore bypasses that rule intentionally. Use it when the index truly does not matter.

What happens under the hood

range evaluates the collection exactly once, before the first iteration. That detail matters when the collection changes during the loop. The compiler creates a hidden copy of the length or a snapshot of the map keys. It then steps through that snapshot.

For slices and arrays, the compiler generates a loop that looks roughly like this in pseudocode:

tmp := collection
len := len(tmp)
for i := 0; i < len; i++ {
    value := tmp[i]
    // run your loop body
}

The compiler does not re-evaluate len(collection) on every pass. It reads the length once. If you append to a slice inside the loop, the range loop does not see the new elements. The iteration length is fixed at the start.

For maps, the compiler extracts the keys into an internal list. It then shuffles that list before iterating. Map iteration order is deliberately randomized. This prevents code from accidentally depending on insertion order, which changes across Go versions and architectures.

For strings, range does not walk bytes. It walks Unicode code points. The compiler decodes UTF-8 sequences on the fly. Each iteration yields the byte offset where the character starts, and the decoded rune. If the string contains invalid UTF-8, the compiler yields the replacement character \uFFFD and advances the byte index by one.

For channels, range blocks until a value arrives or the channel closes. It does not snapshot anything. It pulls from the pipe until the sender calls close(). When the channel closes, the loop drains any buffered values and then exits.

Range is a snapshot for collections, a live drain for channels.

Real-world patterns

You will see range in three places almost every day: configuration maps, text processing, and channel draining. Each pattern has a specific shape.

Map iteration is the standard way to process key-value pairs. You usually care about both the key and the value.

// main.go
package main

import "fmt"

func main() {
    // map holds string keys and integer values
    scores := map[string]int{"alice": 90, "bob": 85}
    
    // range yields key and value for maps
    for name, score := range scores {
        // format the output for readability
        fmt.Printf("%s scored %d\n", name, score)
    }
}

The output order will not match the declaration order. The compiler randomizes it. If you need sorted output, extract the keys, sort them with slices.Sort, then iterate the sorted keys to fetch values from the map.

String iteration reveals the difference between bytes and characters. English text is one byte per character. Emoji or accented letters are two to four bytes. range handles the decoding automatically.

// main.go
package main

import "fmt"

func main() {
    // string contains ASCII and a multi-byte emoji
    greeting := "hi 👋"
    
    // range decodes UTF-8 into runes automatically
    for i, r := range greeting {
        // print byte offset and the decoded character
        fmt.Printf("byte %d: %c\n", i, r)
    }
}

The byte offsets jump from 2 to 5 because the emoji occupies three bytes in UTF-8. If you need raw bytes, iterate over a []byte slice instead. range over a string gives you logical characters. range over a byte slice gives you raw octets. Pick the one that matches your parsing goal.

Channel draining is the cleanest way to consume a stream until it finishes. You spawn a worker that sends results, close the channel when done, and range over it in the main goroutine.

// main.go
package main

import "fmt"

func producer(ch chan<- string) {
    // send three messages then close the channel
    ch <- "first"
    ch <- "second"
    ch <- "third"
    close(ch)
}

func main() {
    // unbuffered channel for simple flow control
    ch := make(chan string)
    
    // start producer in a separate goroutine
    go producer(ch)
    
    // range blocks until channel closes
    for msg := range ch {
        // print each received message
        fmt.Println(msg)
    }
}

The range loop waits for each send. When close(ch) runs, the loop finishes after draining the buffer. If you forget to close the channel, the range loop blocks forever. That is the most common goroutine leak pattern. Always close the sender side, or use a context to cancel the receiver.

Pitfalls and compiler guardrails

The loop variable capture bug used to trip up every Go developer. Before Go 1.22, the compiler reused the same memory address for the loop variable across all iterations. If you captured that variable in a closure or passed its address to a goroutine, every closure pointed to the final value. The compiler now rejects this pattern with loop variable i captured by func literal. Go 1.22 creates a new variable for each iteration, matching developer intuition. If you are on an older version, declare a fresh variable inside the loop body: i := i.

Map iteration order is randomized by design. The compiler shuffles keys to prevent accidental ordering dependencies. If your tests fail because map output changes, sort the keys before printing or comparing. Do not rely on insertion order.

String iteration yields runes, not bytes. If you pass a string to range expecting single-byte indices, your offsets will skip. The compiler does not warn about this because it is valid Go. Use []byte(s) when you need byte-level access. Use range s when you need character-level access.

Channel range blocks until close. The compiler cannot detect missing close() calls at compile time. You get a silent hang at runtime. The worst goroutine bug is the one that never logs. Always pair range over channels with a guaranteed close, or wrap the receiver in a select with a context deadline.

Range is a snapshot for collections, a live drain for channels.

When to reach for range

Use range when you need to visit every element of a slice, array, map, string, or channel without managing indices manually. Use a manual for i := 0; i < len(s); i++ loop when you need to step by two, iterate backwards, or modify the slice in place while tracking exact positions. Use range over a channel when you want to block until a stream finishes and drain all buffered values. Use a select statement when you need to read from multiple channels or add a timeout. Use strings.Split or bytes.Split when you need to break text into tokens before iterating. Use plain sequential code when you only need the first or last element: the simplest thing that works is usually the right thing.

Range handles the boilerplate. You handle the logic.

Where to go next