Go Control Flow: if, for and switch

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

Go 1.24

Go’s control-flow vocabulary is remarkably small: no while, no do-while, and switch does not fall through. Less syntax is only an advantage once you know exactly how the small set behaves.

if: no parentheses, optional initialiser

if score >= 60 {
	fmt.Println("pass")
} else if score >= 40 {
	fmt.Println("retake")
} else {
	fmt.Println("fail")
}

The condition must be a bool. if score and if 1 are compile errors — probably the biggest daily difference from C-family languages.

An if can start with a statement, and any variables it declares live only inside the if/else chain:

if n, err := strconv.Atoi(input); err != nil {
	fmt.Println("not a number:", err)
} else {
	fmt.Println("parsed", n)
}
// n and err are out of scope here

This is the idiomatic shape for “get a value, then check the error”, and it keeps short-lived variables out of the surrounding function.

for: the only loop

Every loop in Go is spelled for. All the classic shapes still exist.

Three-clause form:

for i := 0; i < 3; i++ {
	fmt.Println(i)
}

Condition only (the while role):

n := 1
for n < 100 {
	n *= 2
}

No condition (the while (true) role — you must break out yourself):

for {
	line, err := reader.ReadString('\n')
	if err != nil {
		break
	}
	fmt.Print(line)
}

range form:

nums := []int{10, 20, 30}

for i, v := range nums {
	fmt.Println(i, v)
}

for _, v := range nums {   // value only
	fmt.Println(v)
}

for i := range nums {      // index only
	fmt.Println(i)
}

range hands you a copy of each element, so mutating the loop variable changes nothing:

for _, v := range nums {
	v *= 2          // no effect on nums
}

for i := range nums {
	nums[i] *= 2    // this works
}

Since Go 1.22 the loop variable is per-iteration rather than shared, which fixes the classic “closures capture the last value” bug. Taking the address of the copy is still not the same as taking the address of the element — see the slices chapter.

range over different types

Operand First value Second value
slice / array index copy of element
string byte offset rune
map key value
channel — received value

Map iteration order is deliberately randomised. Go makes it impossible to depend on insertion order by accident:

m := map[string]int{"a": 1, "b": 2, "c": 3}
for k, v := range m {
	fmt.Println(k, v)   // order varies between runs
}

When you need a stable order, collect and sort the keys:

keys := make([]string, 0, len(m))
for k := range m {
	keys = append(keys, k)
}
sort.Strings(keys)

for _, k := range keys {
	fmt.Println(k, m[k])
}

sort.Strings still works, but Go 1.21 added the generic slices.Sort(keys), which is the version to prefer in new code.

Iterating a string yields byte offsets, not character positions:

for i, r := range "a你b" {
	fmt.Printf("%d %c\n", i, r)
}
// 0 a
// 1 你
// 4 b      ("你" occupies bytes 1..3)

switch: implicit break, expression optional

switch status {
case "pending", "running":
	fmt.Println("in progress")
case "done":
	fmt.Println("finished")
default:
	fmt.Println("unknown")
}

The differences from C matter:

  • Each case ends automatically. You cannot fall through by forgetting a break.
  • To fall through you must write fallthrough explicitly, and it is rarely the right answer.
  • A case can list several comma-separated values.
  • The switch value is optional; omitting it is the same as switch true:
switch {
case score >= 90:
	fmt.Println("excellent")
case score >= 60:
	fmt.Println("pass")
default:
	fmt.Println("fail")
}

switch also accepts an initialiser statement:

switch n := len(items); {
case n == 0:
	fmt.Println("empty")
case n < 10:
	fmt.Println("a few")
default:
	fmt.Println("many")
}

switch on a bool with an initialiser is a compact way to avoid repeating len(items) in every condition.

Type switch

When the input is any, use a type switch to branch on the dynamic type:

func describe(v any) string {
	switch x := v.(type) {
	case int:
		return fmt.Sprintf("int %d", x)
	case string:
		return "string " + x
	case nil:
		return "nil"
	default:
		return fmt.Sprintf("other %T", x)
	}
}

Inside each branch x has the narrowed type, so len(x) works in the string case without an assertion. Note that case nil matches an interface that is nil, not an interface holding a nil pointer — the difference is covered in the interfaces chapter.

break, continue and labels

outer:
for _, row := range matrix {
	for _, cell := range row {
		if cell < 0 {
			continue outer   // skip to the next row
		}
		if cell == 0 {
			break outer      // leave both loops
		}
	}
}

The label goes on the statement it names, directly above the for. Compared with carrying a found := false flag around, labels read better; but at three levels of nesting it is usually time to extract a function.

break inside a select or switch only leaves that construct, not an enclosing loop — another reason labels exist.

Common mistakes

1. Writing while or do-while

Those keywords do not exist. Express both with for.

2. Assigning to the range variable and expecting mutation

v is a copy. Index the slice instead.

3. Depending on map iteration order

Randomised on purpose. Sort the keys.

4. Assuming a string index is a character position

It is a byte offset; multi-byte characters make the numbers jump.

5. Expecting C-style fallthrough in switch

Cases break implicitly. Use fallthrough deliberately, not as a fix for a mistake.

6. Using a non-bool condition in if

if 1 does not compile. Write the comparison.

Summary

  • if needs a bool and can scope an initialiser to the whole chain.
  • for covers all four shapes: three-clause, condition-only, infinite and range.
  • range yields copies; map order is random; string iteration gives byte offsets plus runes.
  • switch breaks implicitly, works without an expression, and supports type switches.
  • Labels are the clear way to leave nested loops.

Next: functions and errors — errors are ordinary values, and that changes how you read Go code.