The Copy vs. The Index
You come from Python where for item in list: feels natural. You try to double every number in a Go slice. You write for _, v := range nums { v *= 2 }. The code compiles. The program runs. The numbers stay exactly the same. You stare at the screen. Go didn't crash. It just ignored your changes.
This is the first wall most new Go developers hit. The loop variable is a copy, not a reference. for range hands you a photocopy of each element. If you scribble on the photocopy, the original document remains pristine. To modify the original, you need the address. That's where the index loop comes in.
How range works
for range is the idiomatic way to iterate in Go. It works on slices, arrays, maps, and channels. The syntax is clean, but the semantics are strict. When you iterate over a slice, Go evaluates the slice once at the start. It then steps through the elements. For each element, it creates a fresh copy in the loop variables.
The loop variable lives only for the duration of the iteration body. Once the body finishes, the variable is discarded. The next iteration creates a new copy. This design prevents accidental mutations. It forces you to be explicit when you want to change data.
Here's the difference in action. The first loop reads copies. The second loop mutates the slice directly using the index.
package main
import "fmt"
func main() {
// Start with a slice of integers
nums := []int{1, 2, 3}
// range gives a copy of the value.
// Modifying v changes the local copy, not the slice.
for _, v := range nums {
v *= 2
}
// nums is still [1, 2, 3]. The changes vanished.
// Use the index to modify the slice in place.
// nums[i] accesses the element directly in memory.
for i := 0; i < len(nums); i++ {
nums[i] *= 2
}
// nums is now [2, 4, 6].
fmt.Println(nums)
}
Range gives copies. Index gives access.
When copies save you
The copy behavior is a feature, not a bug. It protects you from aliasing bugs. In languages where loops hand you a reference, it's easy to modify a collection while iterating over it, or to accidentally mutate a struct you thought was read-only. Go makes the default path safe.
This matters most with structs. If you have a slice of structs, range copies the entire struct. Changing a field in the loop variable does nothing to the slice. You must use the index to update the struct in place.
package main
import "fmt"
type User struct {
Name string
Age int
}
func main() {
// A slice of structs
users := []User{
{Name: "Alice", Age: 30},
{Name: "Bob", Age: 25},
}
// range copies the entire struct.
// Changing u.Age here modifies the copy, not the slice.
for _, u := range users {
u.Age += 1
}
// To modify, use the index.
// users[i] targets the struct inside the slice.
for i := range users {
users[i].Age += 1
}
fmt.Println(users)
}
Don't fight the copy. Use the index to mutate.
Pointers change the rules
If your slice holds pointers, the behavior shifts. range still gives you a copy, but it's a copy of the pointer. The pointer still points to the same underlying object. You can mutate the object through the pointer.
This trips up many developers. If you have []*User, iterating with range gives you *User. You can change fields on the user. If you have []User, iterating gives you User. You cannot change fields on the user in the slice.
package main
import "fmt"
type Config struct {
Debug bool
}
func main() {
// Slice of pointers to Config
configs := []*Config{
{Debug: false},
{Debug: false},
}
// range copies the pointer, not the struct.
// c points to the same Config object as the slice.
// Modifying c.Debug updates the object in the slice.
for _, c := range configs {
c.Debug = true
}
// Both configs are now modified.
fmt.Println(configs[0].Debug)
}
Trust the copy. It prevents silent bugs.
Maps and channels
for range behaves differently for maps and channels. Maps are hash tables. Iterating over a map yields keys and values, but the order is random. Go randomizes the hash seed to prevent denial-of-service attacks based on hash collisions. If you need deterministic order, extract the keys and sort them.
Channels block until closed. for v := range ch is syntactic sugar for a loop that receives values until the channel is closed. It's the cleanest way to drain a channel. If the channel isn't closed, the loop blocks forever. This is a common source of deadlocks.
package main
import (
"fmt"
"sort"
)
func main() {
// Maps iterate in random order.
scores := map[string]int{
"Alice": 90,
"Bob": 85,
"Carol": 95,
}
// Extract keys to sort them.
// range over a map does not guarantee order.
keys := make([]string, 0, len(scores))
for k := range scores {
keys = append(keys, k)
}
sort.Strings(keys)
// Iterate in sorted order.
for _, k := range keys {
fmt.Println(k, scores[k])
}
}
Maps are random. Sort if you care about order.
The index loop variants
You have two ways to write an index loop. The traditional for i := 0; i < len(s); i++ and the idiomatic for i := range s. Both give you the index. The range version is shorter and less error-prone. It avoids off-by-one mistakes and works with any iterable type.
The community prefers for i := range s when you only need the index. It's the standard style. Linters like staticcheck may suggest it. If you need to step by a custom amount, like skipping elements or iterating backwards, you must use the traditional loop with a manual increment.
package main
import "fmt"
func main() {
items := []string{"a", "b", "c", "d", "e"}
// Idiomatic index-only loop.
// range provides the index without the value.
for i := range items {
fmt.Println(i, items[i])
}
// Traditional loop for custom stepping.
// Skip every other element by incrementing by 2.
for i := 0; i < len(items); i += 2 {
fmt.Println(i, items[i])
}
}
Use for i := range s for index-only loops. Use for i := 0; i < len(s); i++ for custom stepping.
Pitfalls and errors
The loop variable capture bug is famous in Go history. Before Go 1.22, the loop variable was reused across iterations. If you captured the variable in a closure, all closures shared the same variable. They would all print the last value. Go 1.22 fixed this. The compiler now generates a new variable for each iteration.
If you are on an older version, the compiler warns with loop variable v captured by func literal. Upgrade to Go 1.22+ to get the safe behavior. If you must support old versions, create a new variable inside the loop: v := v.
Channels require a close. If you range over a channel that never closes, your goroutine blocks forever. This leaks the goroutine. Always ensure the sender closes the channel when done. The worst goroutine bug is the one that never logs.
package main
import "fmt"
func main() {
// Channel to send numbers
ch := make(chan int, 3)
// Send values and close the channel.
// Closing signals that no more values will be sent.
ch <- 1
ch <- 2
close(ch)
// range waits for the channel to close.
// It consumes all values then exits cleanly.
for v := range ch {
fmt.Println(v)
}
}
Channels must close. Range waits forever otherwise.
Decision matrix
Use for range when you only need to read values from a slice, array, map, or channel. Use for range with an index when you need the index but don't need the value, or when you want to modify the slice via the index. Use for i := 0; i < len(s); i++ when you need to step by a custom amount, like skipping elements or iterating backwards. Use for i := 0; i < len(s); i++ when you need to modify the slice and prefer the explicit index syntax over for i := range s. Use for v := range ch when you want to consume a channel until it closes.