Skip to content

Control Flow

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 here

This pattern is idiomatic for operations that return (value, error) — it keeps the error variable close to the check and avoids polluting the outer scope.

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 := 1
for n < 100 {
n *= 2
}

Infinite loop — omit the condition entirely; use break to exit:

for {
if done() {
break
}
}

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 bytes
for 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.

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
An unlabeled break only escapes the switch; a labeled break escapes the labeled for

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] — unchanged

It 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 visited

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.

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")
fallthrough
case x > 5:
fmt.Println("medium-or-big") // runs even though this case's own test was never evaluated
default:
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 switch

And it cannot be used at all in a type switch:

./main.go:10:3: cannot fallthrough in type switch

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")
}
}
What you getBenefitCost
One for keywordLess to learn, consistent codeYou must remember the three forms (C-style, condition-only, infinite)
for rangeReads cleanly, no manual index bookkeepingrange 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 fallthroughFewer bugs from a forgotten breakYou 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 statementThe err variable’s scope stays as narrow as possibleThe syntax looks unusual to developers coming from other languages
Labeled break/continuePrecise control over which loop a jump targetsAn unlabeled break inside a switch/select inside a for is a classic, silent bug
  • “range copies the whole slice.” range only 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 for loops always need semicolons like C.” for i := 0; i < n; i++ uses semicolons, but for range and the condition-only loop do not.
  • “break inside a switch that’s inside a for stops the loop.” It only exits the switch (or select) — the loop keeps running. Reach for a labeled break when 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 — range evaluates the slice’s length once, before the first iteration.

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 break inside a switch (or select) that itself sits inside a for? Check whether the intent was to stop the loop — if so, it needs a label.
  • Does a for range loop mutate its value variable (for _, v := range s { v = ... }) expecting the source slice to change? It never will.
  • Does a loop append to the very slice it is ranging over, expecting to visit the newly appended elements? range already fixed the iteration count before the first append ran.
  • Does a fallthrough sit 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/break used 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; ... } }.

What is the Go equivalent of a while loop in other languages?
In Go's switch statement, what happens when a case matches but has no fallthrough keyword?
What scope do variables declared in an if init statement have?
When ranging over a string with for i, r := range s, what type is r?
What does this print?
for i := 0; i < 3; i++ {
    switch i {
    case 1:
        break
    default:
        fmt.Println("processing", i)
    }
}
How many lines does this print?
s := []int{1, 2, 3}
for i, v := range s {
    if i == 0 {
        s = append(s, 4)
    }
    fmt.Println(i, v)
}