Control Flow: One Loop and a Switch With No Surprises

Go has a single loop keyword, an if that can carry its own setup, and a switch that never falls through by accident. What that minimalism buys you, how each form works, and the edges worth knowing. Compiled and run against Go 1.26.5.

Control flow is where a language’s taste for minimalism shows most plainly, and Go’s is aggressive. There is exactly one loop keyword. There is no ternary operator. The switch does not fall through unless you beg it to. Each of these is a deliberate subtraction, and each has a reason rooted in the same goal the whole language serves: code you didn’t write should read the way you expect. This chapter walks the three constructs you steer a program with — for, if, and switch — and the handful of edges that catch people.

Everything here is compiled and run against Go 1.26.5.

The single for

Most languages give you for, while, do-while, and often a foreach on top. Go gives you for, and for alone. That sounds impoverished until you see that the one keyword covers every case by varying which clauses you supply. One construct, four shapes, nothing new to learn for each.

Here they are together:

package main

import "fmt"

func main() {
	// Three-clause form.
	sum := 0
	for i := 1; i <= 5; i++ {
		sum += i
	}
	fmt.Println("sum 1..5:", sum)

	// Condition-only form (the "while").
	n := 1
	for n < 100 {
		n *= 2
	}
	fmt.Println("first power of two >= 100:", n)

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

	// for range over a slice.
	letters := []string{"a", "b", "c"}
	for i, s := range letters {
		fmt.Printf("index %d = %q\n", i, s)
	}
}
$ go run .
sum 1..5: 15
first power of two >= 100: 128
broke at: 3
index 0 = "a"
index 1 = "b"
index 2 = "c"

Four forms, one keyword. The three-clause form (init; condition; post) is the classic C-style counter. Drop the init and post and you have the condition-only form, which is Go’s while in all but name. Drop the condition too and you have the bare infinite loop, for {}, which you leave with break, return, or a panic. And for range iterates a collection, yielding an index and value.

range deserves a note because it works over more than slices: arrays, strings, maps, channels, and — since Go 1.22 — integers (for i := range 5) and, since 1.23, iterator functions. When you only want the value, drop the index with the blank identifier: for _, s := range letters. When you only want the index, take a single variable: for i := range letters. We lean on range constantly in the chapters ahead; here it’s enough to know it’s one of the four for shapes.

if, and the init statement

if looks ordinary — condition, block, optional else — with two things worth calling out. First, the condition has no parentheses and the braces are mandatory, even for a one-line body. There is no brace-less if, which removes a whole category of dangling-else and goto-fail bugs. Second, and more useful, an if can carry an init statement before its condition:

package main

import "fmt"

func main() {
	stock := map[string]int{"apple": 3, "pear": 0}

	// if with an init statement: v, ok scoped to the if/else.
	if qty, ok := stock["apple"]; ok {
		fmt.Println("apple in stock:", qty)
	} else {
		fmt.Println("no apple listed")
	}

	if qty, ok := stock["banana"]; ok {
		fmt.Println("banana in stock:", qty)
	} else {
		fmt.Println("banana not listed, ok =", ok, "qty =", qty)
	}

	// A fresh init for a different check:
	score := 82
	if grade := score / 10; grade >= 9 {
		fmt.Println("A")
	} else if grade >= 8 {
		fmt.Println("B")
	} else {
		fmt.Println("C or below")
	}
}
$ go run .
apple in stock: 3
banana not listed, ok = false qty = 0
B

The pattern if v, ok := m[k]; ok is one you will type a thousand times. The statement before the semicolon runs first, then the condition is evaluated, and the variables it declares are scoped to the if and its else and nowhere beyond. That scoping is the whole point: qty and ok exist exactly where they’re relevant and can’t leak into the surrounding function to be misused later. The same init form appears on switch and for, so learn it once here.

There is no ternary operator in Go. No cond ? a : b. The language designers left it out on purpose, on the grounds that chained ternaries become unreadable and that a three-line if/else is clearer to the next person. You will occasionally miss the terseness. You will more often be glad you never have to decode someone’s nested ? :. When you truly want an expression, a small helper or a plain if that assigns to a pre-declared variable does the job.

switch, without the footgun

C-family switch statements fall through from one case to the next unless you remember a break on every single one, and forgetting is a classic bug. Go inverts the default: each case stands alone, and control leaves the switch when the case finishes. No break needed, and no accidental fallthrough.

package main

import "fmt"

func classify(i any) string {
	// Type switch: preview of ch12.
	switch v := i.(type) {
	case int:
		return fmt.Sprintf("int %d", v)
	case string:
		return fmt.Sprintf("string %q", v)
	default:
		return fmt.Sprintf("other %T", v)
	}
}

func main() {
	// No implicit fallthrough: only the matched case runs.
	for _, day := range []string{"Sat", "Sun", "Mon"} {
		switch day {
		case "Sat", "Sun": // case list
			fmt.Println(day, "-> weekend")
		case "Mon":
			fmt.Println(day, "-> monday")
		default:
			fmt.Println(day, "-> weekday")
		}
	}

	// Expression-less switch as a clean if-else chain.
	score := 72
	switch {
	case score >= 90:
		fmt.Println("A")
	case score >= 70:
		fmt.Println("B")
	default:
		fmt.Println("C")
	}

	// Type switch.
	fmt.Println(classify(7))
	fmt.Println(classify("hi"))
	fmt.Println(classify(3.14))
}
$ go run .
Sat -> weekend
Sun -> weekend
Mon -> monday
B
int 7
string "hi"
other float64

Three things are on display. A case list (case "Sat", "Sun":) matches any of several values, which is how you group cases without falling through. The expression-less switchswitch { with no value after it — treats each case as a boolean condition and runs the first true one, giving you a clean if-else-if ladder that reads top to bottom. And the type switch (switch v := i.(type)) branches on the dynamic type of an interface value, binding v to that type inside each case. We give the type switch its full treatment in the interfaces chapter; here it’s a preview so the syntax isn’t a stranger when it returns.

If you genuinely want fallthrough, Go makes you ask for it with the fallthrough keyword, and even then it’s unconditional — it jumps to the next case’s body without re-checking that case’s condition. It’s rare enough that seeing one should make you look twice. Here’s proof that nothing falls through on its own:

package main

import "fmt"

func main() {
	// Explicit fallthrough, to prove the default is NOT to fall through.
	switch 2 {
	case 1:
		fmt.Println("one")
	case 2:
		fmt.Println("two")
		fallthrough
	case 3:
		fmt.Println("three (reached only via fallthrough)")
	case 4:
		fmt.Println("four (NOT reached)")
	}
}
$ go run .
two
three (reached only via fallthrough)

Case 2 ran, fallthrough forced entry into case 3, and there control stopped — case 4 did not run, because fallthrough only carries you forward one case, not to the end. Without that one keyword, matching 2 would have printed two and nothing else. That’s the default you get everywhere you don’t type fallthrough.

break, continue, labels, and the goto nobody uses

Inside loops, break leaves the innermost loop and continue skips to its next iteration, both as you’d expect. The wrinkle is nested loops: a plain break only escapes one level. When you need to break out of an outer loop from inside an inner one, Go uses labels:

outer:
	for _, row := range grid {
		for _, cell := range row {
			if cell == target {
				break outer // leaves both loops
			}
		}
	}

The label sits before the loop, and break outer (or continue outer) names which loop to act on. It’s the clean alternative to a found boolean threaded through both loops. Labels also work with continue.

Go does have a goto statement, and it can jump to a label within the same function. It exists, it’s occasionally the clearest way to bail out of deeply nested error handling in generated code, and in day-to-day Go you will essentially never write one. Mention it here so you recognize it, then forget it. The structured forms cover everything real code needs.

Final thoughts

Go’s control flow is small on purpose. One for keyword bends into a counter, a while, an infinite loop, and a range iterator. if and switch both take an init statement that scopes a variable to exactly the block that needs it, and the if v, ok := m[k]; ok shape from that idea is everyday Go. switch never falls through unless you write fallthrough, its expression-less form is a tidy if-else ladder, and its type-switch form previews how you’ll branch on interface types later. There’s no ternary, and there’s a goto you’ll never use. None of it is much to memorize, which is the point: the constructs get out of the way so the logic reads plainly.

Next: arrays and slices — the slice header, the append that quietly reallocates, and the shared-backing-array gotcha that trips up everyone at least once.

Comments