for Loops (Go’s Only Loop)
Go has exactly one looping keyword: for. There is no while, no do-while, and no foreach — every kind of repetition in Go, from a simple counted loop to iterating a map to an infinite server loop, is written with for. This is a deliberate design choice: one flexible construct is easier to learn and read than several overlapping ones. By the end of this lesson you will know every form for can take, how it executes under the hood, and the mistakes that catch out even experienced Go developers.
Overview: How Go’s for Loop Works
Every loop in Go, no matter how it looks, is a for statement. The language gives you four shapes of the same keyword:
- The classic three-part loop:
for init; condition; post { }, the same shape you’d recognize from C, Java, or JavaScript. - The condition-only loop:
for condition { }, which behaves exactly like awhileloop in other languages. - The infinite loop:
for { }, which runs forever until abreak,return, oros.Exitstops it. - The
for rangeloop, which iterates over a slice, array, string, map, channel, or integer (Go 1.22+), producing an index/key and a value on each pass.
Unlike C-family languages, Go never requires parentheses around the condition, and the opening brace must sit on the same line as the for keyword. This isn’t just a style rule: Go’s parser performs automatic semicolon insertion at the end of lines, and a brace left dangling on its own line would be parsed as the end of the statement, which fails to compile. All four forms are really the same construct underneath: a condition check followed by a conditional jump back to the top of the body. The compiler doesn’t need separate machinery for “while” versus “for” because, syntactically, they were never different statements to begin with — while is just a for with an empty init and post clause.
A detail that matters a lot in practice is how for range handles its collection. For a slice or array, Go evaluates the range expression exactly once, up front, and remembers the starting length, so if your loop body appends to the slice you’re ranging over, those newly appended elements are never visited in that same loop. On each iteration the index and value variables are assigned by copying: for a slice of structs, the value variable holds a full copy of the struct, not a reference into the underlying array. Mutating that copy has no effect on the original data — a bug dissected below in Common Mistakes. When ranging over a string, Go decodes it as UTF-8 and gives you the byte offset of each character together with the decoded rune (an int32), not a sequential character index, so multi-byte characters cause the index to jump by more than one. Ranging over a map visits key/value pairs in an order the runtime deliberately randomizes on every run, specifically so code can never come to depend on map ordering.
Loop variables declared in the three-part form’s init clause are scoped to the loop itself — a variable declared with := there is not visible after the loop ends. As of Go 1.22, every iteration of a for loop gets its own fresh copy of the loop’s variables; before 1.22, all iterations shared a single variable, which was a frequent source of bugs when a loop launched goroutines or created closures. That history, and the defensive pattern that still works on every Go version, is covered in Common Mistakes.
Syntax
The four forms side by side:
for initialization; condition; post {
// loop body
}
// condition-only, like a "while" loop
for condition {
// loop body
}
// infinite loop
for {
// loop body
}
// range loop
for index, value := range collection {
// loop body
}
| Part | Meaning |
|---|---|
initialization |
Runs exactly once, before the loop starts. Typically declares a counter with :=. |
condition |
A boolean expression evaluated before every iteration. When it is false, the loop exits. |
post |
Runs after each iteration’s body finishes, before the condition is checked again. Typically increments a counter. |
range collection |
Produces an index/key and a value for each element of a slice, array, string, map, or channel. |
break |
Exits the innermost enclosing loop immediately. |
continue |
Skips the remainder of the current iteration and moves on to the post statement / next iteration. |
Examples
Example 1: The classic three-part loop
The most familiar form counts from a start value to an end value, one step at a time.
package main
import "fmt"
func main() {
sum := 0
for i := 1; i <= 10; i++ {
sum += i
}
fmt.Println("Sum 1 to 10:", sum)
}
Output:
Sum 1 to 10: 55
i is declared and initialized to 1. Before each pass, Go checks i <= 10; while that’s true it runs the body, adding i to sum, then runs i++ and checks the condition again. The loop stops the moment i becomes 11, having added every integer from 1 through 10.
Example 2: Condition-only loop (Go’s “while”)
Drop the init and post clauses and you get a loop that behaves like while in other languages.
package main
import "fmt"
func main() {
n := 1
for n < 100 {
fmt.Println(n)
n *= 2
}
}
Output:
1
2
4
8
16
32
64
There is no separate while keyword to learn — this is still a for statement, just with only the middle clause supplied. Go checks n < 100 before every pass; once n reaches 128 the condition is false and the loop ends.
Example 3: Infinite loop with break
Sometimes the exit condition doesn’t fit neatly into a single boolean check up front — for example, scanning for a sentinel value. An infinite for {} combined with break handles that cleanly.
package main
import "fmt"
func main() {
data := []int{4, 8, 15, 16, 23, 42, -1, 99}
total := 0
i := 0
for {
if data[i] == -1 {
break
}
total += data[i]
i++
}
fmt.Println("Total before sentinel:", total)
}
Output:
Total before sentinel: 108
The loop has no condition of its own, so it would run forever if nothing stopped it. Each pass checks whether the current element is the sentinel value -1; if so, break exits the loop immediately, skipping the 99 that follows. This pattern — infinite loop, explicit break — is common for reading from channels or network connections, where “keep going until told to stop” is a more natural fit than a fixed condition.
Example 4: for range with continue
for range is the idiomatic way to walk a slice, and continue lets you skip elements without deeply nesting if statements.
package main
import "fmt"
func main() {
words := []string{"go", "", "is", "fun", ""}
totalChars := 0
for i, w := range words {
if w == "" {
fmt.Printf("skipping empty string at index %d\n", i)
continue
}
totalChars += len(w)
}
fmt.Println("Total characters:", totalChars)
}
Output:
skipping empty string at index 1
skipping empty string at index 4
Total characters: 7
range words yields an index i and a copy of each element w, in order. When w is empty, continue jumps straight to the next iteration without running totalChars += len(w). The final count, 7, is the combined length of "go", "is", and "fun".
How for Loops Execute, Step by Step
For the three-part form, Go follows a strict sequence: run initialization exactly once; check condition — if false, exit the loop without running the body; otherwise, run the body; then run post; then go back and check condition again. This repeats until the condition is false. The important detail beginners miss is that post runs after the body but before the next condition check, which is why i++ in a three-part loop always happens even on the last executed iteration.
break and continue alter this flow directly: break jumps straight past the loop entirely, skipping any remaining iterations and the final condition check; continue jumps to the post statement (in a three-part loop) or straight to the next condition check (in a condition-only or range loop), skipping only the rest of the current body. For nested loops, a bare break or continue only affects the innermost loop; to control an outer loop from inside a nested one, label the outer loop and reference the label: outer: for ... { for ... { break outer } }.
For a for range loop over a slice or array, Go evaluates the range expression once at the start and captures its length at that moment — so appending to the slice inside the loop body does not extend how many iterations run. On each iteration, Go copies the current element into the value variable; this copy is why mutating the value variable never changes the original collection for value types like structs or ints (though it does let you mutate the contents of a pointer, map, or slice value, since those are reference-like types where the copy still points at the same underlying data).
Common Mistakes
Mistake 1: Mutating a copy instead of the original element
Because range copies each element into the loop variable, changes to that variable never reach the original slice when the element type is a struct (or any non-pointer value type).
package main
import "fmt"
type Item struct {
Name string
Price int
}
func main() {
items := []Item{{"pen", 10}, {"cup", 20}}
for _, item := range items {
item.Price *= 2
}
fmt.Println(items)
}
Output:
[{pen 10} {cup 20}]
This compiles cleanly and gives no warning, which is exactly what makes it dangerous — the prices are unchanged because item is a fresh copy on every iteration. The fix is to index into the slice directly, which reaches the real element:
package main
import "fmt"
type Item struct {
Name string
Price int
}
func main() {
items := []Item{{"pen", 10}, {"cup", 20}}
for i := range items {
items[i].Price *= 2
}
fmt.Println(items)
}
Output:
[{pen 20} {cup 40}]
Ranging with just the index and writing through items[i] mutates the actual backing array, so the change is visible after the loop ends.
Mistake 2: Capturing the loop variable in a goroutine or closure
Before Go 1.22, every iteration of a for loop shared the same loop variable — a goroutine launched inside the loop that referenced it directly would often see the value from a later iteration (or the same final value) rather than the value from the iteration that launched it:
for i, n := range numbers {
go func() {
results[i] = n * n // bug on Go versions before 1.22: i and n are shared, not per-iteration
}()
}
Go 1.22 fixed this by giving each iteration its own copy of the loop variables, so the snippet above is actually safe on modern Go. Even so, the explicit-parameter pattern below remains the recommended style: it works identically on every Go version, and it makes the per-iteration intent obvious to anyone reading the code without needing to know which compiler version fixed the old behavior.
package main
import (
"fmt"
"sync"
)
func main() {
numbers := []int{10, 20, 30}
results := make([]int, len(numbers))
var wg sync.WaitGroup
for i, n := range numbers {
wg.Add(1)
go func(i, n int) {
defer wg.Done()
results[i] = n * n
}(i, n)
}
wg.Wait()
fmt.Println(results)
}
Output:
[100 400 900]
Passing i and n as parameters to the goroutine’s function literal copies their current values at the moment go func(...) is called, so each goroutine works with the values from its own iteration regardless of scheduling order. Writing to results[i] is safe here because every goroutine writes to a distinct index — there’s no shared memory being written concurrently, so no data race, and wg.Wait() guarantees all writes finish before fmt.Println reads the slice.
Best Practices
- Prefer
for rangeover manual indexing when you don’t need to skip elements or step by more than one — it’s shorter and eliminates off-by-one errors. - When you need to mutate elements of a slice of structs, range by index (
for i := range s) and write throughs[i], not through the range value. - Pass loop variables as explicit parameters to goroutines and deferred closures (
go func(i int) { ... }(i)); it’s correct on every Go version and self-documenting, even though Go 1.22+ no longer strictly requires it. - Avoid infinite
for {}loops without an obvious, nearby exit path — abreak,return, or cancellation check should be easy to spot near the top of the body. - Use a labeled
breakorcontinueto control an outer loop from a nested one instead of a boolean “found” flag — it’s clearer and avoids an extra check on every iteration. - Never rely on the order
for rangevisits a map’s keys — it’s intentionally randomized. Sort the keys first if a deterministic order matters. - Use
_to discard an index or value you don’t need (for _, v := range s) rather than declaring a variable you never use — Go won’t compile an unused local variable. - Keep loop bodies short; if the body grows past a few lines of real logic, extract it into a named function called from the loop.
Practice Exercises
1. Write a program that uses a three-part for loop to print all even numbers from 2 to 20 inclusive, on separate lines.
2. Given a slice of integers, use for range to build a new slice containing only the values greater than 10, using continue to skip the rest. Print the resulting slice. Expected output for []int{5, 12, 8, 30, 2, 17} is [12 30 17].
3. Write a function that takes a slice of *Item (pointers to a struct with a Price field) and doubles every price using for range — since the elements are already pointers, this should work correctly without indexing. Explain in a comment why this version doesn’t have the copy problem from Mistake 1.
Summary
- Go has one loop keyword,
for, with four shapes: three-part, condition-only (“while”), infinite, andfor range. - The opening brace must be on the same line as
for; there are no parentheses around the condition. for rangecopies each element into the loop variable — mutating that copy does not change the original slice unless you index in directly or the element is itself a pointer, map, or slice.breakexits the innermost loop entirely;continueskips to the next iteration; labels let both target an outer loop.- Go 1.22+ gives each loop iteration its own copy of loop variables, fixing a classic goroutine-capture bug — but passing loop variables as explicit function parameters remains the clearest, most portable style.
- Map iteration order is intentionally randomized — never depend on it.
