{} The Go Reference

Basics · Go · Beginner

Control Flow

if with an init statement, the single for loop in all its forms, switch without fall-through, labeled break, and defer.

Basics Beginner ⏱ 9 min read Complete

🚦 Analogy

Most languages hand you a drawer full of looping tools — while, do-while, for, foreach. Go gives you one screwdriver, for, that fits every screw. Fewer keywords, fewer decisions, and code that always reads the same way. The same minimalism runs through the rest of Go’s control flow: one loop, a switch that doesn’t fall through, and defer to handle “do this on the way out.”

The philosophy: few constructs, no surprises

Go deliberately keeps its control-flow vocabulary tiny. There is one loop keyword, a switch that breaks by default, branch conditions that need no parentheses, and a single mechanism — defer — for cleanup. The goal is that any Go programmer can read any other’s control flow at a glance, with no clever syntax to decode. Everything below is a variation on a handful of forms.

A thread running through all of them is block scope: if, for, and switch can each begin with a short init statement whose variables live only inside that construct. This keeps temporaries — especially err — from leaking into the surrounding function.

if — with an optional init

if takes no parentheses around its condition, and it can run a short statement first. Any variables that statement declares are scoped to the if and its else branches, and nowhere else:

if score > 90 {
	grade = "A"
} else if score > 80 {
	grade = "B"
}

// the idiomatic Go pattern: handle the error right where it happens
if err := save(data); err != nil {
	return err // err exists only inside this if/else chain
}

The init form is everywhere in Go. It pairs a call with the test of its result and confines the result to the smallest possible scope — so err from one operation can’t accidentally be read (or shadowed) by the next.

for — the only loop

graph LR
F["for"] --> A["for i := 0; i < n; i++  (three-clause)"]
F --> B["for cond  (while)"]
F --> C["for  (infinite, break to exit)"]
F --> D["for i, v := range xs  (range)"]

One keyword, four shapes. The first three are the C-style loop with clauses progressively dropped; the fourth is range:

for-forms.go — editable & runnable
package main

import "fmt"

func main() {
// 1. classic three-clause: init; condition; post
sum := 0
for i := 1; i <= 5; i++ {
	sum += i
}
fmt.Println("sum 1..5 =", sum) // 15

// 2. condition-only — Go's "while"
n := 8
steps := 0
for n > 1 {
	n /= 2
	steps++
}
fmt.Println("halvings of 8:", steps) // 3

// 3. infinite loop with an explicit break
count := 0
for {
	count++
	if count == 3 {
		break
	}
}
fmt.Println("broke at:", count) // 3

// 4. range — here over a slice, giving index and value
for i, name := range []string{"go", "rust", "zig"} {
	fmt.Printf("%d: %s\n", i, name)
}
}

continue skips to the next iteration; break exits the loop. The post statement (i++) runs after the body and before the next condition test.

range over everything

range adapts to what you iterate. The two loop variables mean different things per type, and you can drop the ones you don’t need with _ (or omit them entirely). Note that ranging over a map visits keys in random order by design — never rely on it.

You range overFirst varSecond var
slice / arrayindexelement (a copy)
stringbyte offsetrune (decoded UTF-8)
mapkeyvalue
channelreceived value— (one variable only)
integer n (Go 1.22+)0 … n-1
range-forms.go — editable & runnable
package main

import (
"fmt"
"sort"
)

func main() {
// range over a string yields byte offset + rune (decoded UTF-8).
for i, r := range "Gö" { // ö is two bytes
	fmt.Printf("offset %d: %c\n", i, r)
}

// range over a map — order is random, so sort keys for deterministic output.
ages := map[string]int{"ana": 30, "bo": 25}
keys := make([]string, 0, len(ages))
for k := range ages { // one variable = keys only
	keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
	fmt.Printf("%s=%d\n", k, ages[k])
}

// range over a channel drains it until closed.
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)
total := 0
for v := range ch {
	total += v
}
fmt.Println("channel total:", total) // 6

// Go 1.22+: range over an integer runs n times (0..n-1).
for i := range 3 {
	fmt.Println("tick", i)
}
}

Since Go 1.23, range also accepts an iterator function — a function of type iter.Seq[V] or iter.Seq2[K, V] (a “range-over-func”). This lets your own types and the slices/maps package helpers (slices.All, maps.Keys, …) drive a for range loop just like a built-in collection, without materializing a slice.

switch — clean multi-way branching

A Go switch breaks after each case automatically (no break), so it reads cleanly. It comes in several flavors, all on display here: an expression switch that compares a value, a tagless switch (no condition — a tidy if/else if chain), multi-value cases grouped with commas, an optional init statement, and the explicit fallthrough keyword for the rare case you want C-style continuation:

switch-forms.go — editable & runnable
package main

import "fmt"

func classify(n int) string {
switch { // tagless switch = if/else-if chain
case n < 0:
	return "negative"
case n == 0:
	return "zero"
default:
	return "positive"
}
}

func main() {
for _, n := range []int{-3, 0, 7} {
	fmt.Printf("%d is %s\n", n, classify(n))
}

// expression switch with an init statement and multi-value cases
switch day := "Sat"; day {
case "Sat", "Sun":
	fmt.Println("weekend")
default:
	fmt.Println("weekday")
}

// fallthrough continues into the NEXT case unconditionally
switch n := 1; n {
case 1:
	fmt.Println("one")
	fallthrough // jump into case 2's body without testing it
case 2:
	fmt.Println("...and through to two")
case 3:
	fmt.Println("not reached")
}
}

A type switch is a special form that branches on the dynamic type of an interface value — including pointer types — binding the value to a new variable of the matched type. It’s covered in depth in type assertions and interfaces:

switch v := x.(type) {
case nil:        // x holds no value
case *bytes.Buffer: // a pointer type is a valid case
	v.WriteString("…") // v is *bytes.Buffer here
case fmt.Stringer:  // interfaces work as cases too
	_ = v.String()
default:
	// v has x's original type
}

Labeled break and continue

A plain break/continue only affects the innermost loop. To jump out of (or restart) an outer loop from deep inside, attach a label to it and name the label. This replaces the flag-variable dance you’d otherwise need:

labeled-break.go — editable & runnable
package main

import "fmt"

func main() {
grid := [][]int{
	{2, 4, 6}, // contains evens
	{1, 5, 9}, // all odd
	{7, 5, 8}, // contains an even (8)
}

target := 5
var foundR, foundC int

search: // label on the outer loop
for r, row := range grid {
	for c, v := range row {
		if v == target {
			foundR, foundC = r, c
			break search // exits BOTH loops at once
		}
	}
}
fmt.Printf("found %d at [%d][%d]\n", target, foundR, foundC) // [1][1]

// continue with a label restarts the OUTER loop's next iteration.
rows:
for r, row := range grid {
	for _, v := range row {
		if v%2 == 0 { // any even value disqualifies the whole row
			continue rows // skip to the next row immediately
		}
	}
	fmt.Println("all-odd row index:", r) // only row 1 ({1,5,9}) qualifies
}
}

Labels also work with goto, but goto is rare in idiomatic Go; labeled break/continue covers almost every real need.

defer — cleanup on the way out

defer schedules a function call to run when the surrounding function returns — by any path, including a panic. It’s how Go handles cleanup without try/finally: open a resource and immediately defer its close, so the two live side by side and the close can’t be forgotten. Two rules are essential to get right:

  1. Deferred calls run in LIFO order — last deferred, first executed.
  2. Arguments are evaluated when defer runs, not when the call finally fires. The call is postponed; the arguments are snapshotted immediately.
defer-semantics.go — editable & runnable
package main

import "fmt"

func main() {
// LIFO order: deferred calls fire in reverse.
fmt.Println("start")
defer fmt.Println("deferred 1 (runs last)")
defer fmt.Println("deferred 2")
defer fmt.Println("deferred 3 (runs first)")
fmt.Println("end of body")

demoArgEval()
}

func demoArgEval() {
i := 0
// Argument evaluated NOW (i == 0), call deferred.
defer fmt.Println("deferred sees i =", i)
// A closure sees the variable LATER (its final value).
defer func() { fmt.Println("closure sees i =", i) }()
i = 99
fmt.Println("body sets i =", i)
}

Output proves both rules: the main defers print 3, 2, 1; in demoArgEval, the direct defer fmt.Println("…", i) captured i == 0, while the closure printed the final i == 99.

⚠️ defer inside a loop

defer is tied to the function, not the block, so a defer inside a for loop does not run at the end of each iteration — every deferred call piles up and fires only when the whole function returns. Open a thousand files in a loop with defer f.Close() and you hold a thousand handles open at once, possibly exhausting the OS limit. Fixes: close at the end of each iteration explicitly, or move the loop body into its own function so each call’s defer fires per iteration:

for _, name := range files {
	func() {
		f, _ := os.Open(name)
		defer f.Close() // now fires at the end of THIS call
		// ... use f ...
	}()
}

Also beware combining defer with named return values: a deferred closure can modify a named result before it’s returned — powerful for wrapping errors, surprising if unintended.

See also

  • Variables & types — the := init statements and the shadowing that scope rules prevent.
  • Functions — multiple returns, named results, and how defer interacts with them.
  • Errors — the if err != nil pattern that the init form was built for.
  • Panic & recover — how a deferred call can intercept a panic.
  • Type assertions — the type-switch form in full.

Next: packaging logic into reusable units — functions.

Check your understanding

Score: 0 / 5

1. How many looping keywords does Go have?

Go has exactly one loop keyword, `for`. Drop the clauses for a while-loop (`for cond {}`), drop everything for an infinite loop (`for {}`), or use `for range` to iterate over collections and (since Go 1.22) integers.

2. What happens after a matching `case` in a Go `switch`?

Go switches break automatically after the matching case — no `break` needed. If you actually want to continue into the next case, use the explicit `fallthrough` keyword, which jumps unconditionally (it does not re-test the next case's condition).

3. When are a deferred call's arguments evaluated?

Arguments to a deferred function are evaluated at the moment the defer statement executes; only the call itself is postponed. So `defer fmt.Println(i)` captures i's value now, even though the print happens later. Closures, by contrast, see the variable's final value.

4. What's special about the variable in `if v := f(); v > 0 {`?

The optional init statement declares variables scoped to the if and all its else branches — perfect for the `if err := do(); err != nil {` pattern, keeping err out of the wider scope. switch and for accept the same init clause.

5. In nested loops, how do you break out of the OUTER loop from inside the inner one?

A plain `break`/`continue` only affects the innermost loop. Attach a label to the outer loop (`outer:`) and write `break outer` or `continue outer` to target it. This is the idiomatic alternative to a flag variable.

Comments

Sign in with GitHub to join the discussion.