One Keyword, Three Shapes, No while
Go has for and nothing else. There is no while, no do…while, no foreach — every repetition you will ever write is a for, in one of three shapes.
// 1. Three-clause: init; condition; post — all three optional
for i := 0; i < len(items); i++ {
fmt.Println(items[i])
}
// 2. Condition-only: the while form
for pending > 0 {
pending = drain()
}
// 3. Endless: no condition — loop until break
for {
if done() {
break
}
time.Sleep(time.Second)
}
The three-clause form reads as setup, test, step: the init statement runs once, the condition is checked before each pass (a bare for ; cond; omits setup — legal but rare), and the post statement runs after each body. All three clauses being optional is what collapses while into the same keyword: with no init and no post, the shape is a while loop, and with nothing at all you get the endless form, which every event loop, retry block, and consumer goroutine in Go is built from. The escape hatch in all three shapes is break; continue skips to the post statement (or re-tests the condition when there is no post).
Details that shape the code around loops:
i++andi--are statements, not operators —for i++is not valid, and you cannot writex := i++. (The full operator story is in Operators and Expressions.)- The init variable is scoped to the loop:
for i := 0; …putsinowhere else in the function, which prevents accidental use of a stale counter after the loop. - Braces are mandatory and the opening brace stays on the
forline — the semicolon-insertion rule from Conditionals with if and else applies unchanged. - A range over an integer works too:
for i := range 10iterates 0 through 9 (Go 1.22 and later, gated by your module'sgoline — see the last chapter).
A three-clause loop with an index is the right tool when you need the position: computing neighbors, writing into result[i], or stepping with a non-unit stride. When the index is incidental and the elements are the point, Go hands the job to range — a form with its own semantics worth learning one collection at a time.
range: Four Collections, Four Sets of Rules
for x := range collection is the same keyword with a different right-hand side, and each collection type brings different promises about order, copying, and blocking.
Slices and arrays yield index and element:
for i, v := range nums {
result[i] = v * 2 // v is a COPY of nums[i]
}
The copy is not a detail: assigning to v writes to the loop's private variable, not back into the slice — a misunderstanding that makes "my update vanished" bugs. Mutate through the index (nums[i] = …) or range pointers (for _, p := range &points — where the element is a *T and p.X = … writes through it). Either skip the index (for _, v := range) or the value (for i := range nums) when unneeded, and for range nums when you only need the repetitions. The compiler copies elements per iteration, so for large structs range by index or pointer rather than hauling a copy through every pass — the copy rule in full is Value Semantics and Copies.
Maps yield key and value, with a promise of disorder:
for k, v := range counts {
fmt.Println(k, v) // order is intentionally unspecified — randomized
}
The iteration order is not stable between runs, by design: code that sorts keys depends on it, and code that forgot to sort fails only sometimes. This is why a test asserting a fixed print order of map keys flakily fails — sort the keys, or compare sets. Deleting entries you have already visited during range is safe and is the idiom for pruning; entries not yet reached may or may not appear, so building a new map is cleaner than deleting wholesale.
Strings yield a byte offset and a decoded rune:
for i, r := range "héllo" {
fmt.Printf("%d %c\n", i, r) // i jumps 0,1,3,4,5 — é occupies two bytes
}
The index is where the rune starts in the underlying bytes, so s[:i]-style arithmetic by increment assumes ASCII; for character work convert first with []rune(s) — the byte/rune mismatch is unpacked in Iterating Strings, Bytes, and Runes.
Channels yield values as they arrive, blocking until one is available, and the loop ends when the channel closes:
for msg := range jobs {
handle(msg)
} // runs until close(jobs) — see Channel Fundamentals for why
A channel that is never closed leaves this loop blocked forever, which is the first deadlock every Go programmer writes. The pattern and its closing discipline live in Channel Fundamentals. One rule spans all four: what you receive is a value (or a key/index), not a reference back into the collection — write through explicit indices or pointers when mutation is the goal.
break Only Knows the Innermost Block — Until You Label It
break and continue aim at the nearest enclosing for, switch, or select — and "nearest" is doing real work in that sentence. The two places this bites:
for _, row := range grid {
switch classify(row) {
case skip:
continue // continues the SWITCH's... wait: continues the FOR
case bad:
break // exits the SWITCH — the loop keeps going!
}
process(row)
}
A bare break inside a switch leaves the switch, not the loop — so an "abort this row" branch that breaks instead of continues will fall through to process(row) anyway. There is no such ambiguity with continue: inside a switch nested in a loop, continue belongs to the loop, because switch has no concept of continuing. When you need an outer break, you name the loop:
search:
for i := range rows {
for _, cell := range rows[i] {
if cell == target {
found = [2]int{i, index}
break search // exits both loops at once
}
}
}
The label sits immediately before the for it names, and break search (or continue search) applies to that loop from any depth. Labels are rare in ordinary code — if you need them in more than a couple of places, the usual refactor is extracting the nested search into a function and returning, which deletes the nesting instead of jumping out of it. Labels earn their keep exactly where a helper function would obscure a tight algorithm: scanning a grid, probing a matrix, breaking out of an event pump.
The remaining control keywords: goto exists and jumps backward/forward within a function, but Go's structured style treats it as a last resort — no modern Go idiom needs it. And fallthrough, which you met in the switch topic, is a break's opposite: it enters the next case rather than leaving, which is why it is confined to expression switches.
Knowing all exits of a loop — break, labeled break, continue, labeled continue, and the implicit exit when the condition fails or the channel closes — is the surface area of iteration. The last chapter covers what the loop hands to everything around it: the variable itself, and the trap that shaped a generation of Go bug fixes.
The Loop Variable Capture Bug — and the Fix Written Into Go 1.22
Before 2024, this was one of the most reliable Go bugs to write:
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(i) // pre-1.22: 3 3 3 (the final value, three times)
}()
}
wg.Wait()
The goroutines run after the loop finishes, and they all read the same i — one variable, declared once, mutated each iteration — so they see its last state. The identical trap appears with closures stored in a slice, callbacks, and anything else that outlives the iteration. The old workaround is famous: i := i at the top of the body, creating a fresh variable per pass that the closure captures.
Go 1.22 changed the language: each iteration declares its own loop variable. The snippet above now prints 0 1 2 — in some order, since goroutine scheduling decides the sequence — with no workaround. for _, v := range items likewise binds a new v every pass, so captured closures see the value from their own iteration.
The part that matters in production: the semantics follow the go line in your module's go.mod, not the compiler running the build. A module declaring go 1.21 or older keeps per-loop variables when compiled by Go 1.25 — the toolchain preserves old behavior for old modules (tunable via GODEBUG=loopvar=1 in the other direction). So "which behavior do I have?" is answered by reading two lines of metadata, the same version property introduced in Installing the Go Toolchain. When you join a repository and a loop capture looks buggy but shouldn't be, check that go line first.
Three rules to leave with:
- If your module's
goline is 1.22+, closures in loops capture per-iteration values — the bug is gone for new code. - If you maintain older modules, either raise the
goline (the right long-term fix) or keep thev := vidiom — harmless under new semantics, required under old. - Never rely on goroutines inside a loop finishing "in order": the value is now well-defined, the scheduling never was — wait on a
WaitGroupor channel for completion.
Loop mechanics end here; the closures and goroutines that made this trap famous are ahead — Anonymous Functions and Closures and Starting Goroutines — and the function declarations they attach to begin the next phase in Function Declarations and Multiple Returns.
Quiz
1. Go has no `while` keyword. How do you write a loop that repeats while `pending > 0`?
2. What happens after this loop runs? ```go nums := []int{1, 2, 3} for _, v := range nums { v = v * 10 } fmt.Println(nums) ```
3. Inside a for loop you have a switch, and a case executes `break`. What exits?
4. A module declares `go 1.20` in go.mod and launches goroutines that capture the loop variable i. What do the goroutines see, and what fixes it?
Flashcards
Question
What are the three forms of Go's for loop, and which one replaces while?
Click to reveal answer
Answer
Three-clause (`for i := 0; i < n; i++`, all clauses optional), condition-only (`for pending > 0 {` — this is Go's while), and endless (`for {` with break). There is no separate while/do-while keyword; the endless form backs event loops and consumer goroutines that exit only via break or channel closure.
Question
What does ranging over a map promise about order, and what code breaks because of it?
Click to reveal answer
Answer
Nothing — iteration order is intentionally unspecified and randomized between runs. Tests that assert a fixed print order of map keys, or algorithms that accumulate results by appending in range order (dedup, joins, serialized output), fail intermittently; the fix is sorting keys or comparing order-insensitively.
Question
In nested loops with a switch inside, how do you break out of the outer loop?
Click to reveal answer
Answer
Label the outer loop and break to it: `outer:` before the for, then `break outer` from any depth. A bare break exits only the nearest enclosing construct — the switch — leaving the loop to continue, while a bare continue targets the loop (switch has no continue), which is a common source of 'my abort doesn't abort' bugs.
Question
When ranging over a string, what are the index and the value?
Click to reveal answer
Answer
The index is the byte offset where the rune begins in the UTF-8 encoding; the value is the decoded rune itself. For "héllo" the indices jump 0,1,3,4,5 because é occupies two bytes — so byte-based slicing arithmetic assumes ASCII, and character-level work should range over []rune(s) instead.
Question
How did Go 1.22 change loop-variable capture, and what determines the behavior your build uses?
Click to reveal answer
Answer
Each iteration now declares its own loop variable, so closures and goroutines capture that iteration's value instead of a single shared variable that ends at its final value (pre-1.22's 'prints 3,3,3' bug; the old workaround was i := i). The behavior follows the go line in go.mod — a module declaring go 1.21 keeps old semantics even on a new toolchain — so checking that line answers which rules apply.
Revision Notes
Key Takeaways
- 1. One keyword, three shapes: three-clause, condition-only (while), and endless — plus integer range since Go 1.22
- 2. range delivers copies: assignments to the loop value never reach the collection, so mutate through indices or pointers
- 3. Map order is unspecified by design; string indices are byte offsets; channel range blocks until close
- 4. break targets the nearest for/switch/select — inside a switch it leaves the switch, and a label is the only way out of an outer loop
- 5. Loop-variable capture follows go.mod's go line: per-iteration variables from 1.22, shared variable before — check the metadata before trusting either
Interview Tips
- • "How many loop constructs does Go have?" — only for, in three shapes; explain why while was folded in (condition-only form) and note `for i := range n` as the modern counting form
- • "Why does my map iteration order differ between runs?" — it's randomized on purpose to break order-dependent logic; mention sorting keys for deterministic output and that a flaky ordering test is a symptom, not a Go bug
- • "break inside a switch inside a loop — what does it affect?" — the switch only; for the outer loop you need a label (or extract a function and return), and continue targets the loop even inside the switch
- • "Explain the classic goroutine-in-loop bug" — shared loop variable pre-1.22 → all goroutines see the final value; fix was i := i, now built in — and add that the behavior is keyed to go.mod's go line, which is the detail that separates a solid answer from a memorized one
Cheat Sheet
Loops with for
Forms:
| Form | Meaning |
|---|---|
for i := 0; i < n; i++ { } |
three-clause; any clause may be omitted |
for cond { } |
condition-only — Go's while |
for { } |
endless — exit with break |
for i := range 10 { } |
integer range, 0..9 (Go 1.22+, gated by go.mod) |
range behavior by collection:
| Collection | Yields | Watch out |
|---|---|---|
| slice/array | index, element copy | mutate via xs[i] or pointers only |
| map | key, value | order randomized; sort keys for stable output |
| string | byte offset, rune | offset jumps across multi-byte runes |
| channel | received value | blocks until closed; never-closed = hang |
Exits: break = nearest for/switch/select · continue = next iteration (loop, even inside switch) · break label / continue label = named outer loop · condition false / channel closed = natural exit.
Code template:
search:
for i, row := range grid {
for j, cell := range row {
if cell == target {
found = [2]int{i, j}
break search
}
}
}
// per-iteration capture (pre-1.22 workaround, still idiomatic)
for _, job := range jobs {
job := job
go func() { process(job) }()
}
Loop-variable semantics: go.mod go ≥ 1.22 → per-iteration variables; older → one shared variable (last value wins for late closures).
Continue Learning
Before You Start
- No prerequisites listed.