Go For Loops: The Only Loop in Go

Go's designers made a deliberate choice: one loop construct to rule them all. While languages like Java, C++, and Python offer `for`, `while`, `do-while`, and various iterator patterns, Go provides...

Key Insights

  • Go consolidates all looping constructs into a single for keyword that handles traditional loops, while-style loops, infinite loops, and range-based iteration—eliminating the need for while, do-while, and foreach.
  • The range keyword transforms the for loop into a powerful iterator over slices, arrays, maps, strings, and channels, with Go 1.22+ fixing the notorious loop variable capture bug that plagued earlier versions.
  • Common gotchas include closure problems with goroutines (pre-1.22), modifying collections during iteration, and unnecessary value copies when ranging over large structs—all avoidable with proper patterns.

Introduction: Why Go Has Only One Loop Construct

Go’s designers made a deliberate choice: one loop construct to rule them all. While languages like Java, C++, and Python offer for, while, do-while, and various iterator patterns, Go provides only the for keyword. This isn’t a limitation—it’s a feature.

The philosophy stems from Go’s core principle of simplicity. Why memorize multiple looping constructs when one can handle every iteration scenario? The for loop in Go is remarkably flexible, adapting its behavior based on the components you provide. This approach reduces cognitive overhead and makes code more consistent across projects.

Let’s explore how this single construct handles everything from classic C-style loops to modern collection iteration.

Classic Three-Component For Loop

The traditional for loop syntax will feel familiar to anyone coming from C, Java, or JavaScript. It consists of three components: initialization, condition, and post statement.

package main

import "fmt"

func main() {
    // Basic counting loop
    for i := 0; i <= 10; i++ {
        fmt.Println(i)
    }
}

The initialization (i := 0) runs once before the loop starts. The condition (i <= 10) is checked before each iteration. The post statement (i++) executes after each iteration. All three components are optional, giving you flexibility.

Reverse iteration works exactly as you’d expect:

func countdown() {
    for i := 10; i >= 0; i-- {
        fmt.Printf("%d... ", i)
    }
    fmt.Println("Liftoff!")
}

You can also declare multiple variables in the initialization, though the condition must be a single boolean expression:

func fibonacci(n int) {
    for i, a, b := 0, 0, 1; i < n; i++ {
        fmt.Println(a)
        a, b = b, a+b
    }
}

For as a While Loop

Drop the initialization and post statement, and you’ve got a while loop. This pattern is perfect when you don’t know how many iterations you’ll need upfront.

func readUntilEmpty(scanner *bufio.Scanner) {
    for scanner.Scan() {
        line := scanner.Text()
        if line == "" {
            break
        }
        fmt.Println("Processing:", line)
    }
}

This pattern shines when working with channels:

func processMessages(messages <-chan string) {
    for msg := range messages {
        fmt.Println("Received:", msg)
    }
}

// Or with a condition-based approach
func drainChannel(ch <-chan int) {
    for {
        select {
        case val, ok := <-ch:
            if !ok {
                return // Channel closed
            }
            fmt.Println(val)
        default:
            return // No more messages
        }
    }
}

The condition-only form makes your intent clear: keep looping while this condition holds true.

Infinite Loops and Break/Continue

Remove all components, and you get an infinite loop. This isn’t a bug—it’s a feature for servers, event loops, and other continuously-running processes.

func server() {
    for {
        conn, err := listener.Accept()
        if err != nil {
            if isShutdown(err) {
                break // Exit the loop cleanly
            }
            log.Println("Accept error:", err)
            continue // Skip this iteration, keep serving
        }
        go handleConnection(conn)
    }
}

The break statement exits the loop entirely. The continue statement skips to the next iteration. Use them to control flow without deeply nested conditionals:

func processNumbers(numbers []int) {
    for _, num := range numbers {
        if num%2 == 0 {
            continue // Skip even numbers
        }
        if num > 100 {
            break // Stop at first number over 100
        }
        fmt.Println("Processing:", num)
    }
}

For nested loops, you can label loops and break/continue to specific levels:

func findInMatrix(matrix [][]int, target int) (int, int) {
outer:
    for i, row := range matrix {
        for j, val := range row {
            if val == target {
                break outer // Break out of both loops
            }
        }
    }
    return -1, -1
}

Range-Based For Loops

The range keyword is where Go’s for loop really shines. It provides clean iteration over collections without manual index management.

For slices and arrays, range returns both index and value:

func sumSlice(numbers []int) int {
    sum := 0
    for index, value := range numbers {
        fmt.Printf("numbers[%d] = %d\n", index, value)
        sum += value
    }
    return sum
}

Maps return key-value pairs, though iteration order is randomized:

func printConfig(config map[string]string) {
    for key, value := range config {
        fmt.Printf("%s: %s\n", key, value)
    }
}

Strings iterate over Unicode code points (runes), not bytes:

func analyzeString(s string) {
    for index, runeValue := range s {
        fmt.Printf("Character at byte %d: %c (Unicode: %U)\n", 
            index, runeValue, runeValue)
    }
}

// Example: "Hello, 世界"
// Prints byte positions and proper Unicode characters

When you don’t need the index or key, use the blank identifier:

func hasNegative(numbers []int) bool {
    for _, num := range numbers {
        if num < 0 {
            return true
        }
    }
    return false
}

// Or when you only need the index
func printIndices(items []string) {
    for i := range items {
        fmt.Println("Index:", i)
    }
}

Channels can be ranged over, automatically breaking when the channel closes:

func consumeJobs(jobs <-chan Job) {
    for job := range jobs {
        job.Execute()
    }
    // Loop exits when jobs channel is closed
}

Common Patterns and Gotchas

The loop variable capture problem plagued Go developers for years. Before Go 1.22, loop variables were reused across iterations, causing subtle bugs:

// Pre-Go 1.22: BUGGY CODE
func launchWorkers(tasks []Task) {
    for _, task := range tasks {
        go func() {
            task.Execute() // All goroutines see the LAST task!
        }()
    }
}

// Pre-1.22 fix: explicitly capture the variable
func launchWorkersFixed(tasks []Task) {
    for _, task := range tasks {
        task := task // Shadow the loop variable
        go func() {
            task.Execute() // Now each goroutine has its own copy
        }()
    }
}

// Go 1.22+: Works correctly without the shadow
func launchWorkersModern(tasks []Task) {
    for _, task := range tasks {
        go func() {
            task.Execute() // Each iteration gets a new variable
        }()
    }
}

Modifying a slice while iterating is dangerous. The range clause evaluates the slice once at the start:

// DANGEROUS: Can cause unexpected behavior
func removeEvens(numbers []int) []int {
    for i, num := range numbers {
        if num%2 == 0 {
            numbers = append(numbers[:i], numbers[i+1:]...)
        }
    }
    return numbers
}

// SAFE: Build a new slice
func removeEvensCorrect(numbers []int) []int {
    result := make([]int, 0, len(numbers))
    for _, num := range numbers {
        if num%2 != 0 {
            result = append(result, num)
        }
    }
    return result
}

When ranging over large structs, you get copies. This can hurt performance:

type LargeStruct struct {
    data [1000]int
    // ... more fields
}

// INEFFICIENT: Copies entire struct each iteration
func processInefficient(items []LargeStruct) {
    for _, item := range items {
        processItem(item) // item is a copy
    }
}

// EFFICIENT: Use index to avoid copies
func processEfficient(items []LargeStruct) {
    for i := range items {
        processItem(items[i]) // No copy, direct access
    }
}

// Or use pointers in your slice
func processPointers(items []*LargeStruct) {
    for _, item := range items {
        processItem(*item) // Only copying a pointer
    }
}

Conclusion: Embracing Simplicity

Go’s single loop construct isn’t a compromise—it’s a design triumph. By consolidating all iteration patterns into one flexible keyword, Go reduces the mental overhead of choosing between loop types while maintaining complete expressive power.

The three-component form handles traditional counting loops. Drop components to get while-style loops. Add range for collection iteration. Use bare for for infinite loops. This consistency makes Go code easier to read and write, especially in large codebases where multiple developers need to maintain coherent style.

The modern improvements in Go 1.22, particularly fixing the loop variable capture bug, have eliminated one of the language’s most notorious footguns. Combined with the existing power of range and the simplicity of the syntax, Go’s for loop demonstrates that sometimes less really is more.

Master these patterns, watch for the common gotchas, and you’ll find that Go’s single loop construct handles everything you need—without the complexity baggage of multiple looping keywords.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.