The single loop that does everything
You come from Python. You have for i in range(10), while x < 10, and for item in list. You come from JavaScript. You have for (let i=0...), while, for...of, for...in, and forEach. You open a Go file and see for. Just for. No while. No do-while. No foreach. It feels like the language forgot half its vocabulary. It didn't. Go stripped the loop syntax down to a single keyword that does everything. The trade-off is less syntax to memorize and a compiler that catches loop mistakes you'd miss in other languages.
Concept: One keyword, three shapes
Think of for as a Swiss Army knife. In other languages, you have a dedicated screwdriver, a dedicated knife, and a dedicated saw. Go hands you one tool with interchangeable heads. The keyword is always for. The behavior changes based on what you put inside the parentheses. You can include an initializer, a condition, and a post-statement. You can drop any of those three parts. The compiler figures out which mode you want based on what's present.
This design forces consistency. Every loop looks like a loop. You never have to guess if a block is a while or a for at a glance. The language designers removed while and do-while because they believed those constructs added complexity without adding capability. Any loop you can write with while can be written with for. The single keyword reduces cognitive load. You learn one pattern instead of three.
Go doesn't have fewer loops. It has fewer keywords.
Minimal example: The C-style loop
Here's the classic C-style loop, which Go keeps because it's useful for index-based iteration and arithmetic progressions.
package main
import "fmt"
func main() {
// Standard loop: init, condition, post.
// i starts at 0. Loop runs while i < 5. i increments after each body.
for i := 0; i < 5; i++ {
fmt.Println(i)
}
}
The initializer i := 0 runs once before the loop starts. The condition i < 5 is checked before every iteration. The post-statement i++ runs after the body, before the next condition check. This order is fixed. You can't accidentally skip the increment by placing it at the top of the body.
Scope is tight. Variables don't leak.
Walkthrough: Scope and order
When the compiler sees for i := 0; i < 5; i++, it treats the initializer i := 0 as part of the loop setup. The variable i is scoped strictly to the loop. You cannot access i after the loop finishes. This prevents a whole class of bugs where a loop variable leaks into the outer scope and holds a stale value.
// i is not accessible here.
// The compiler rejects this with an undefined variable error.
// fmt.Println(i)
for i := 0; i < 5; i++ {
// i exists here.
fmt.Println(i)
}
// i is not accessible here either.
// The scope ends at the closing brace of the loop.
If you need the variable after the loop, declare it outside. This makes the intent explicit.
// Declare i outside if you need it after the loop.
// This signals that i has a purpose beyond the iteration.
i := 0
for i < 5 {
fmt.Println(i)
i++
}
// i is accessible here and holds the value 5.
fmt.Println("Final:", i)
The condition can be any boolean expression. It doesn't have to involve the loop variable. You can loop while a flag is true, or while a channel is open, or while an error is nil. The post-statement can be any statement. You can increment, decrement, call a function, or do nothing.
Realistic example: Range over slices and maps
Real Go code rarely uses index loops for collections. You use range. range is the idiomatic way to iterate over slices, maps, and channels. It returns the index and the value.
package main
import "fmt"
func main() {
// Slice of names to process.
names := []string{"Alice", "Bob", "Charlie"}
// Range yields index and value for each element.
// idx is the position. name is a copy of the string.
for idx, name := range names {
// Use idx for logging or skipping. Use name for the actual data.
fmt.Printf("Index %d: %s\n", idx, name)
}
}
If you only need the value and don't care about the index, use the blank identifier _. This tells the compiler you intentionally ignored the index. It also prevents the "declared and not used" error.
// Discard index with _ when you only need the value.
// This avoids unused variable errors and signals intent.
for _, name := range names {
fmt.Println(name)
}
Here's a crucial detail about range. It yields a copy of the element. If you modify the value variable inside the loop, you're modifying the copy, not the underlying slice. To mutate the slice, you must use the index.
// Range yields a copy. Modifying val does not change the slice.
for _, val := range names {
val = "modified" // This changes nothing in the slice.
}
// Use the index to mutate the underlying data.
for i := range names {
names[i] = "modified" // This updates the slice.
}
Maps work the same way, but range yields the key and the value. Map iteration order is randomized by the runtime. This prevents bugs where code accidentally relies on insertion order. If you need sorted keys, collect the keys, sort them, and loop over the sorted slice.
// Range over a map yields key and value.
// Order is randomized. Never rely on map iteration order.
scores := map[string]int{"Alice": 90, "Bob": 85}
for name, score := range scores {
fmt.Printf("%s: %d\n", name, score)
}
Range is your default. Index loops are for math.
Realistic example: Infinite loops and channels
Infinite loops are common in Go. Servers, workers, and event loops run forever until explicitly stopped. You write an infinite loop by omitting the condition.
// Infinite loop runs forever until break or return.
// Common in servers and event loops.
for {
// Process one request.
// If shutdown signal received, break.
break
}
You exit an infinite loop with break, return, or panic. break jumps to the statement after the loop. return exits the function. panic stops the program.
Channels integrate naturally with range. You can range over a channel to receive values until the channel is closed. The loop blocks when the channel is empty and exits when the channel is closed.
// Range over a channel blocks until a value is sent.
// The loop exits only when the channel is closed.
ch := make(chan string)
// Sender goroutine sends values and closes the channel.
go func() {
ch <- "hello"
ch <- "world"
close(ch) // Closing signals that no more values will be sent.
}()
// Receiver ranges over the channel.
// This blocks until values arrive and stops when ch is closed.
for msg := range ch {
fmt.Println(msg)
}
If you forget to close the channel, the receiver goroutine waits forever. This is a goroutine leak. Always close the channel when the sender is done.
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path.
Pitfalls: Capture, order, and leaks
Loop variable capture is the most famous Go loop pitfall. In Go versions before 1.22, the loop variable was shared across all iterations. If you captured it in a closure, every closure would see the final value. Go 1.22 fixed this by creating a new variable per iteration. The compiler now enforces this.
If you write code that captures a loop variable in a goroutine or function literal, and you're on an older version or doing something weird, the compiler complains with loop variable i captured by func literal. This error forces you to be explicit. You must create a local copy inside the loop body if you need to capture the value.
// Go 1.22+ handles this safely, but understanding the pattern helps.
// Create a local copy to capture the current iteration value.
for _, name := range names {
// name is a new variable in each iteration in Go 1.22+.
// In older Go, you would need: name := name
go func() {
fmt.Println(name)
}()
}
Modifying a slice while ranging over it is dangerous. The range loop uses an internal index. If you append to the slice, the behavior depends on capacity. If the slice grows beyond capacity, a new backing array is allocated. The range loop might miss elements or panic if you're not careful. The safe pattern is to build a new slice.
// Modifying a slice while ranging is unsafe.
// Build a new slice instead.
filtered := make([]string, 0)
for _, name := range names {
if len(name) > 3 {
filtered = append(filtered, name)
}
}
If you accidentally access an index that doesn't exist, the runtime panics with runtime error: slice bounds out of range. This happens if you mix index loops with slice modifications. Stick to range for reading, and build a new slice for filtering.
The compiler catches capture bugs. Trust the error.
Decision: Which loop shape fits your code
Use a for loop with range when iterating over slices, maps, or channels. It's the idiomatic way to traverse collections and handles index/value unpacking automatically.
Use a for loop with an initializer and post-statement when you need an index for arithmetic or when the iteration logic isn't a simple collection traversal.
Use a for loop with only a condition when you need a while-loop behavior, such as waiting for a flag to change or processing until a state is reached.
Use an infinite for loop when you are building a server, a worker, or a long-running process that should run until explicitly stopped.
Use a for loop with range and a blank identifier when you only need the values and want to signal that the index is irrelevant.
Pick the loop shape that matches the data. Don't force a while-loop where a range belongs.