Loops and Iterations
Deep dive into Go's for loop - the only loop construct you need. Master range iterations, break, continue, and loop patterns for common programming tasks.
Programs repeat operations constantly. Process every line in a file. Retry a network request until it succeeds. Calculate the sum of numbers in a list. Handle incoming HTTP requests indefinitely. Repetition is fundamental.
Most languages provide multiple loop constructs: for, while, do-while, foreach. Go has one: for. This single construct handles every looping scenario through different forms. It's not a limitation—it's a design choice that reduces the decisions you make when writing loops.
The Basic For Loop
The three-clause for loop looks familiar if you've used C, Java, or JavaScript:
package main
import "fmt"
func main() {
for i := 0; i < 5; i++ {
fmt.Println(i)
}
}
Output:
0
1
2
3
4
The three parts are: initialization (i := 0), condition (i < 5), and post statement (i++). The loop executes as long as the condition is true. The post statement runs after each iteration.
Loop Execution Flow
Understanding how loops execute helps you reason about loop behavior. Here's the execution flow for a basic for loop:
for i := 0; i < 3; i++ {
fmt.Println(i)
}
Execution diagram:
┌──────────────────┐
│ 1. Initialize │
│ i := 0 │
└────────┬─────────┘
│
▼
┌──────────────────┐
┌───▶│ 2. Check │
│ │ i < 3? │
│ └────────┬─────────┘
│ │
│ true │ false
│ ▼ ▼
│ ┌──────────────────┐ ┌──────────────┐
│ │ 3. Execute │ │ Exit Loop │
│ │ Body │ └──────────────┘
│ └────────┬─────────┘
│ │
│ ▼
│ ┌──────────────────┐
│ │ 4. Execute │
│ │ Post (i++) │
│ └────────┬─────────┘
│ │
└─────────────┘
Step-by-step execution:
Iteration 1:
- Initialize: i = 0
- Check: 0 < 3? → true
- Execute: fmt.Println(0) → prints "0"
- Post: i++ → i = 1
Iteration 2:
- Check: 1 < 3? → true
- Execute: fmt.Println(1) → prints "1"
- Post: i++ → i = 2
Iteration 3:
- Check: 2 < 3? → true
- Execute: fmt.Println(2) → prints "2"
- Post: i++ → i = 3
Iteration 4:
- Check: 3 < 3? → false
- Exit loop
The initialization runs once. Then the condition is checked, the body executes, the post statement runs, and the cycle repeats until the condition becomes false.
Memory state during loop:
┌─────────────────────────────────────┐
│ Stack Frame │
├─────────────────────────────────────┤
│ Before loop: │
│ (no i variable exists) │
│ │
│ After initialization (i := 0): │
│ i: 0 │
│ │
│ After first iteration (i++): │
│ i: 1 │
│ │
│ After second iteration (i++): │
│ i: 2 │
│ │
│ After third iteration (i++): │
│ i: 3 │
│ │
│ After loop exits: │
│ (i goes out of scope) │
└─────────────────────────────────────┘
The loop variable i exists only within the loop scope. It's created during initialization and destroyed when the loop exits.
You can omit the semicolons and write this on separate lines if it's clearer, but the single-line form is conventional:
for i := 0; i < 10; i++ {
fmt.Println(i * i)
}
The initialization creates a variable scoped to the loop. The variable i doesn't exist outside the loop:
for i := 0; i < 5; i++ {
fmt.Println(i)
}
fmt.Println(i) // Compile error: undefined: i
This scoping prevents loop variables from leaking into surrounding code. You can reuse the same variable name in different loops without conflict.
Omitting Clauses
You can omit any of the three clauses. Drop the initialization and post statement to create a while-style loop:
i := 0
for i < 5 {
fmt.Println(i)
i++
}
This form works well when the loop variable exists before the loop or updates in complex ways:
retries := 0
for retries < maxRetries {
if tryConnect() {
break
}
retries++
time.Sleep(time.Second)
}
Omit all three clauses for an infinite loop:
for {
// Run forever
handleRequest()
}
Infinite loops are common in servers that process requests continuously:
func main() {
for {
conn, err := listener.Accept()
if err != nil {
log.Println(err)
continue
}
go handleConnection(conn)
}
}
The loop accepts connections forever. Each connection spawns a goroutine to handle it while the loop continues accepting new connections.
Break and Continue
break exits the loop immediately:
for i := 0; i < 10; i++ {
if i == 5 {
break
}
fmt.Println(i)
}
// Prints 0, 1, 2, 3, 4
continue skips to the next iteration:
for i := 0; i < 10; i++ {
if i%2 == 0 {
continue
}
fmt.Println(i)
}
// Prints 1, 3, 5, 7, 9
Both keywords work with labels to break out of nested loops:
outer:
for i := 0; i < 3; i++ {
for j := 0; j < 3; j++ {
if i == j {
break outer
}
fmt.Println(i, j)
}
}
The break outer exits both loops, not just the inner one. Without the label, break would only exit the inner loop.
Labels are rare in Go code. Most nested loop scenarios have cleaner solutions through functions or better loop structure. But when you need them, they exist.
Range Over Slices
The range keyword iterates over collections. For slices, range provides both index and value:
numbers := []int{10, 20, 30, 40}
for i, num := range numbers {
fmt.Println(i, num)
}
Output:
0 10
1 20
2 30
3 40
If you only need the value, use the blank identifier to discard the index:
for _, num := range numbers {
fmt.Println(num)
}
If you only need the index, omit the second variable:
for i := range numbers {
fmt.Println(i)
}
The blank identifier _ tells the compiler you're intentionally ignoring a value. Without it, an unused variable would be a compilation error. This explicit discard makes intent clear.
Range creates copies of the values. Modifying num doesn't change the slice:
numbers := []int{1, 2, 3}
for _, num := range numbers {
num = num * 2
}
fmt.Println(numbers) // Still [1, 2, 3]
To modify slice elements, use the index:
for i := range numbers {
numbers[i] = numbers[i] * 2
}
fmt.Println(numbers) // [2, 4, 6]
Range Over Maps
Range works on maps, providing key and value:
ages := map[string]int{
"Alice": 30,
"Bob": 25,
"Carol": 35,
}
for name, age := range ages {
fmt.Println(name, age)
}
Map iteration order is randomized. Run this multiple times and you'll get different orders. This randomization is intentional—it prevents code from depending on iteration order, which isn't guaranteed.
If you need sorted keys, extract them to a slice and sort:
import "sort"
keys := make([]string, 0, len(ages))
for name := range ages {
keys = append(keys, name)
}
sort.Strings(keys)
for _, name := range keys {
fmt.Println(name, ages[name])
}
Like slices, you can ignore the key or value:
// Just keys
for name := range ages {
fmt.Println(name)
}
// Just values
for _, age := range ages {
fmt.Println(age)
}
Range Over Strings
Range over strings iterates by runes (Unicode code points), not bytes:
s := "Hello, 世界"
for i, r := range s {
fmt.Printf("%d: %c\n", i, r)
}
Output:
0: H
1: e
2: l
3: l
4: o
5: ,
6:
7: 世
10: 界
Notice the index jumps from 7 to 10. The Chinese characters take 3 bytes each in UTF-8. The index is the byte position, but range decodes UTF-8 to give you complete runes.
If you need to iterate bytes instead of runes, convert to a byte slice:
for i, b := range []byte(s) {
fmt.Printf("%d: %d\n", i, b)
}
This gives you raw bytes, useful for binary data or when working with non-UTF-8 encodings.
Range Over Channels
Range can iterate over channels, receiving values until the channel closes:
ch := make(chan int)
go func() {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch)
}()
for num := range ch {
fmt.Println(num)
}
The loop receives from ch until it closes. When the channel closes, the loop exits. This pattern is common for producer-consumer scenarios where one goroutine sends values and another processes them.
If the channel never closes, the range loop blocks forever waiting for values. We'll explore channels deeply in the concurrency article, but the range syntax appears here.
Common Patterns
Counting backwards:
for i := 10; i >= 0; i-- {
fmt.Println(i)
}
Stepping by values other than 1:
for i := 0; i < 100; i += 10 {
fmt.Println(i)
}
Multiple loop variables:
for i, j := 0, 10; i < j; i, j = i+1, j-1 {
fmt.Println(i, j)
}
This updates both variables in the post statement. The assignment i, j = i+1, j-1 evaluates both right sides before assigning, so swapping works: i, j = j, i.
Looping until a condition:
for {
line, err := reader.ReadLine()
if err != nil {
break
}
process(line)
}
Infinite loops with explicit breaks are clearer than complex loop conditions when the exit condition is in the middle of the loop body.
Why One Loop Construct
Go could have separate for, while, and foreach keywords. Other languages do. Go chose to use for with different forms instead.
This reduces cognitive load. You don't decide which loop keyword fits your scenario—you use for and write the appropriate form. The syntax is simpler: one keyword, multiple patterns.
The three-clause form handles counting. Omitting clauses handles condition-based loops. Range handles collection iteration. One construct covers everything.
This unification appears throughout Go. The language prefers fewer constructs that compose rather than specialized syntax for every scenario.
Performance Considerations
Range over slices is as efficient as index-based iteration. The compiler optimizes both to similar code. Use whichever is clearer.
Range creates copies of values. For large structs, this means copying data:
type Large struct {
data [1000]int
}
items := []Large{...}
for _, item := range items {
// item is a copy of the struct (4KB each)
process(item)
}
If copying is expensive, iterate by index and use pointers:
for i := range items {
process(&items[i])
}
Or use a slice of pointers instead of a slice of values. We'll explore pointers and memory in the next article.
Nested Loops
Nested loops work as expected:
for i := 1; i <= 3; i++ {
for j := 1; j <= 3; j++ {
fmt.Printf("%d * %d = %d\n", i, j, i*j)
}
}
Deeply nested loops often indicate an opportunity to extract a function:
for _, user := range users {
processUser(user)
}
func processUser(user User) {
for _, order := range user.Orders {
processOrder(order)
}
}
func processOrder(order Order) {
for _, item := range order.Items {
processItem(item)
}
}
This structure is easier to test, understand, and modify than three levels of nested loops in one function.
Loop Variables and Closures
A subtle issue occurs when capturing loop variables in closures:
values := []int{1, 2, 3}
for _, v := range values {
go func() {
fmt.Println(v)
}()
}
time.Sleep(time.Second)
You might expect this to print 1, 2, 3 in some order. It often prints 3, 3, 3. The goroutines capture the variable v, not its value. By the time goroutines run, the loop has finished and v holds the last value.
The fix is to pass the value or create a new variable:
for _, v := range values {
v := v // Create new variable for each iteration
go func() {
fmt.Println(v)
}()
}
// Or pass as parameter
for _, v := range values {
go func(val int) {
fmt.Println(val)
}(v)
}
Go 1.22 changed this behavior—loop variables now have per-iteration scope. But understanding the issue helps when reading older code or working with pre-1.22 Go versions.
What's Next
Loops let you repeat operations over data. The next step is organizing that data. Go provides arrays for fixed-size sequences, slices for dynamic sequences, and maps for key-value associations. These composite types combine with loops to process collections efficiently.
Understanding how loops work with collections is essential. Range makes iteration clean, but you need to know what you're iterating over—the memory layout, performance characteristics, and behavior of Go's collection types.
The pattern continues: Go provides minimal syntax that handles necessary cases. One loop construct with multiple forms covers everything from counting to collection iteration. This simplicity makes Go code consistent across projects and teams.
Ready to explore arrays and slices?
