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’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
fallthroughexplicitly, 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
ifneeds a bool and can scope an initialiser to the whole chain.forcovers all four shapes: three-clause, condition-only, infinite andrange.rangeyields copies; map order is random; string iteration gives byte offsets plus runes.switchbreaks 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.