Control Flow
if statements
Section titled “if statements”A basic if in Go looks familiar, but parentheses around the condition are omitted — the braces are always required:
if x > 0 { fmt.Println("positive")}Go adds an init statement separated by a semicolon before the condition. Variables declared in the init are scoped to the entire if/else block:
if err := doWork(); err != nil { fmt.Println("error:", err) return}// err is not in scope hereThis pattern is idiomatic for operations that return (value, error) — it keeps the error variable close to the check and avoids polluting the outer scope.
for — Go’s only loop
Section titled “for — Go’s only loop”Go has one loop keyword: for. It covers all three common loop shapes.
C-style loop — init; condition; post:
for i := 0; i < 5; i++ { fmt.Println(i)}Condition-only loop (equivalent to while in other languages):
n := 1for n < 100 { n *= 2}Infinite loop — omit the condition entirely; use break to exit:
for { if done() { break }}for range
Section titled “for range”range iterates over arrays, slices, strings, maps, and channels. It returns an index and a value on each iteration:
fruits := []string{"apple", "banana", "cherry"}for i, v := range fruits { fmt.Println(i, v)}
// Discard the index with _for _, v := range fruits { fmt.Println(v)}
// Range over a string yields runes (Unicode code points), not bytesfor i, r := range "Go!" { fmt.Printf("%d: %c\n", i, r)}Since Go 1.23, range can also drive a for loop over a function with a special signature — this is the basis of the iter package and custom iterators, covered fully in Iterators once you have seen more of the type system.
Range over an integer, and per-iteration loop variables
Section titled “Range over an integer, and per-iteration loop variables”Since Go 1.22 range also accepts an integer, which replaces the classic counting loop when you only need the index:
for i := range 5 { // 0, 1, 2, 3, 4 fmt.Println(i)}for range 3 { // repeat three times, no variable fmt.Println("tick")}Go 1.22 also changed loop-variable semantics: each iteration gets a fresh i (and v), so closures and goroutines started inside the loop no longer all see the final value. The old i := i copy is unnecessary in modules with go 1.22 or later in go.mod — the full before/after comparison, with real output from both, is in Functions.
Labeled break and continue
Section titled “Labeled break and continue”A break or continue normally applies to the innermost for. A label — an identifier followed by a colon, placed on the line before a for — lets you name that loop and target it from a nested loop:
outer:for i := 0; i < 3; i++ { for j := 0; j < 3; j++ { if j == 1 { continue outer // skip to the next i, not the next j } if i == 2 { break outer // exit both loops entirely } fmt.Println(i, j) }}continue outer skips the rest of the inner loop and the rest of that iteration of the outer loop; break outer exits both loops immediately.
break inside switch or select doesn’t exit the loop
Section titled “break inside switch or select doesn’t exit the loop”This is one of the most common bugs in generated Go code. A bare break exits the innermost enclosing switch or select, not an outer for — even though the loop is the thing most people mean to stop:
for i := 0; i < 3; i++ { switch i { case 1: break // exits only this switch — the loop keeps going default: fmt.Println("processing", i) }}This prints processing 0 and processing 2 — case 1 runs its break, which does nothing except leave the switch, and the for loop happily continues to i = 2. The same rule applies to a select inside a for (covered in the concurrency module once channels are introduced): a break in one of its cases only leaves the select. To actually stop the loop, label it and break the label:
loop:for i := 0; i < 5; i++ { switch { case i == 3: break loop // this one really does exit the for default: fmt.Println(i) }}flowchart LR
subgraph Unlabeled["plain break inside a switch"]
direction TB
U1["for i := ..."] --> U2["switch i { case: break }"]
U2 -->|"exits switch only"| U3["loop continues to next i"]
end
subgraph Labeled["break loop (labeled)"]
direction TB
L1["loop: for i := ..."] --> L2["switch i { case: break loop }"]
L2 -->|"exits the labeled for"| L3["loop stops entirely"]
end range copies elements, and evaluates its expression once
Section titled “range copies elements, and evaluates its expression once”Two behaviors of range surprise newcomers, and both matter when reading generated code:
It copies each element into the loop variable. Mutating that variable never changes the underlying collection:
nums := []int{1, 2, 3}for _, v := range nums { v *= 10 // modifies the local copy only}fmt.Println(nums) // [1 2 3] — unchangedIt evaluates the ranged expression exactly once, at the start of the loop. For a slice, that means the loop bound is fixed to the slice’s length at that moment — appending to the slice inside the loop body does not make the loop visit the new elements:
s := []int{1, 2, 3}for i, v := range s { if i == 0 { s = append(s, 4) // extends s, but range already captured the original length } fmt.Println(i, v)}// loop body still only runs for i = 0, 1, 2 — the appended 4 is never visitedswitch
Section titled “switch”Go’s switch evaluates cases top to bottom and stops at the first match — there is no automatic fallthrough to the next case. You do not need break at the end of each case:
switch day {case "Monday", "Tuesday", "Wednesday", "Thursday", "Friday": fmt.Println("weekday")case "Saturday", "Sunday": fmt.Println("weekend")default: fmt.Println("unknown")}Like if, switch accepts an init statement before the condition, scoped to the whole switch — handy for computing the value you are branching on right at the switch itself:
switch x := score(); {case x > 10: fmt.Println("big")case x > 5: fmt.Println("medium")default: fmt.Println("small")}Switch-true (expressionless switch, as used above) acts as a cleaner if/else chain, with each case an independent boolean expression. To match multiple values in one case, list them comma-separated.
Fallthrough’s limits
Section titled “Fallthrough’s limits”To fall through to the next case explicitly, use fallthrough as the last statement in a case. It is unconditional — it runs the next case’s body regardless of that case’s own condition:
switch x := score(); {case x > 10: fmt.Println("big") fallthroughcase x > 5: fmt.Println("medium-or-big") // runs even though this case's own test was never evaluateddefault: fmt.Println("other")}fallthrough has two hard limits the compiler enforces. It cannot appear in the last case of a switch:
./main.go:9:3: cannot fallthrough final case in switchAnd it cannot be used at all in a type switch:
./main.go:10:3: cannot fallthrough in type switchA one-sentence goto
Section titled “A one-sentence goto”goto jumps to a labeled statement in the same function and exists mainly for generated code and rare cleanup patterns; idiomatic hand-written Go almost never uses it, reaching for a labeled loop, an early return, or a restructured function instead.
package main
import "fmt"
func classify(n int) string { switch { case n < 0: return "negative" case n == 0: return "zero" case n < 10: return "small" default: return "large" }}
func main() { // C-style for loop fmt.Println("-- C-style loop --") for i := 1; i <= 3; i++ { fmt.Println(i) }
// Condition-only loop (while equivalent) fmt.Println("-- condition-only loop --") n := 1 for n < 10 { n *= 2 } fmt.Println("n =", n)
// for range over slice fmt.Println("-- range over slice --") fruits := []string{"apple", "banana", "cherry"} for i, v := range fruits { fmt.Println(i, v) }
// switch fmt.Println("-- switch classify --") for _, val := range []int{-5, 0, 7, 42} { fmt.Println(val, "->", classify(val)) }
// if with init statement fmt.Println("-- if with init --") if x := 16; x%2 == 0 { fmt.Println(x, "is even") } else { fmt.Println(x, "is odd") }} The second playground demonstrates every depth topic above in one runnable program: labeled break/continue, break trapped inside a switch, range’s copy-and-evaluate-once behavior, and switch with an init statement plus fallthrough.
package main
import "fmt"
func score() int { return 12 }
func main() { // Labeled break and continue reach an outer loop by name. fmt.Println("-- labeled continue/break --")outer: for i := 0; i < 3; i++ { for j := 0; j < 3; j++ { if j == 1 { continue outer } if i == 2 { break outer } fmt.Println(i, j) } }
// A plain break inside a switch only exits the switch, not the loop. fmt.Println("-- break inside switch inside for --") for i := 0; i < 3; i++ { switch i { case 1: break // exits only this switch — the loop keeps going default: fmt.Println("processing", i) } }
// A labeled break reaches all the way out of the for. fmt.Println("-- labeled break exits the for --")loop: for i := 0; i < 5; i++ { switch { case i == 3: break loop default: fmt.Println(i) } }
// range copies each element; mutating v does not change the slice. fmt.Println("-- range copies elements --") nums := []int{1, 2, 3} for _, v := range nums { v *= 10 } fmt.Println(nums)
// range evaluates its expression once: appending during the loop does // not extend how many times the loop runs. fmt.Println("-- range evaluates its expression once --") s := []int{1, 2, 3} for i, v := range s { if i == 0 { s = append(s, 4) } fmt.Println(i, v) } fmt.Println("final s:", s)
// switch with an init statement, and fallthrough running the next // case unconditionally. fmt.Println("-- switch init + fallthrough --") switch x := score(); { case x > 10: fmt.Println("big") fallthrough case x > 5: fmt.Println("medium-or-big") default: fmt.Println("other") }} Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
One for keyword | Less to learn, consistent code | You must remember the three forms (C-style, condition-only, infinite) |
for range | Reads cleanly, no manual index bookkeeping | range copies each element’s value and evaluates its expression once — both surprise code that mutates in place or appends mid-loop |
switch with no automatic fallthrough | Fewer bugs from a forgotten break | You must write fallthrough explicitly on the rare occasion you want it, and it cannot appear in the last case or a type switch |
if/switch init statement | The err variable’s scope stays as narrow as possible | The syntax looks unusual to developers coming from other languages |
| Labeled break/continue | Precise control over which loop a jump targets | An unlabeled break inside a switch/select inside a for is a classic, silent bug |
Common Misconceptions
Section titled “Common Misconceptions”- “
rangecopies the whole slice.”rangeonly copies the value of each element, not the whole slice — and the slice header itself is evaluated only once, at the start of the loop. - “Go
forloops always need semicolons like C.”for i := 0; i < n; i++uses semicolons, butfor rangeand the condition-only loop do not. - “
breakinside aswitchthat’s inside aforstops the loop.” It only exits theswitch(orselect) — the loop keeps running. Reach for a labeledbreakwhen you actually mean to stop the loop. - “Appending to a slice while ranging over it makes the loop see the new elements.” It does not —
rangeevaluates the slice’s length once, before the first iteration.
Reading AI-generated code
Section titled “Reading AI-generated code”Control-flow bugs are some of the easiest for generated code to hide, because the program still compiles and often still runs — it just does the wrong number of iterations or exits the wrong thing:
- Is there a bare
breakinside aswitch(orselect) that itself sits inside afor? Check whether the intent was to stop the loop — if so, it needs a label. - Does a
for rangeloop mutate its value variable (for _, v := range s { v = ... }) expecting the source slice to change? It never will. - Does a loop
appendto the very slice it is ranging over, expecting to visit the newly appended elements?rangealready fixed the iteration count before the first append ran. - Does a
fallthroughsit in what turns out to be the last case, or inside a type switch? Both are compile errors, not runtime surprises. - Is a labeled
continue/breakused where a plain one was intended, or vice versa — check which loop the label actually names.
🐛 Spot the bug
for i := 0; i < 3; i++ { switch i { case 1: break default: fmt.Println("processing", i) }}Show the answer
The break inside case 1 only exits the switch statement — it has no effect on the enclosing for loop, which keeps running normally. The real output is processing 0 followed by processing 2: i == 1 does nothing but leave the switch, and the loop proceeds straight to i == 2. If the intent was to stop the loop entirely at i == 1, label the loop and write break loop instead: loop: for i := 0; i < 3; i++ { switch i { case 1: break loop; ... } }.