Golang Control Flow: If, Switch and For
Published Updated Golang 13 min read
One loop keyword covering every form, switch that does not fall through, the statement-scoped variable in an if, and the loop variable that stopped being shared in Go 1.22.
Go has three control-flow keywords and no ternary operator, no while, and no do. That is fewer
constructs than most languages, and the ones that remain carry more: for is every loop, and
switch is both a dispatch and a replacement for the else if chain.
Written against Go 1.22.
if, with a statement
if err := doWork(); err != nil {
return fmt.Errorf("work failed: %w", err)
}
// err is out of scope here
The optional statement before the condition is the idiom that shapes most Go code. err exists only
inside the if and its else, so the next call can declare its own err without shadowing anything
— and a stale err from three calls ago cannot be tested by accident.
No parentheses around the condition, and the braces are mandatory even for one statement. The opening
brace must be on the same line, because Go inserts a semicolon at the end of a line that ends in a
value. Putting { on the next line makes the if body an empty block and is a compile error rather
than a silent bug.
The condition must be a bool. There is no truthiness: if x where x is an int does not compile.
if value, ok := lookup(key); ok {
use(value)
} else {
// value and ok are visible here too
}
No ternary
max := a
if b > a {
max = b
}
There is no a > b ? a : b. The official position is that a ternary invites unreadable nesting, and
the four-line version is what Go offers. Since Go 1.21 the builtins min and max cover the common
case:
larger := max(a, b)
smaller := min(a, b)
for is the only loop
for i := 0; i < 10; i++ { } // three-clause
for i < 10 { } // condition only — this is "while"
for { break } // infinite
for i, v := range slice { } // range
Four forms, one keyword. The condition-only form is Go’s while, and the bare for is the
for(;;) idiom that a server’s accept loop uses.
range adapts to the type:
for i, v := range slice { } // index, value
for k, v := range m { } // key, value — order is deliberately randomised
for i, r := range str { } // byte offset, rune
for v := range ch { } // receive until the channel is closed
for i := range 10 { } // Go 1.22: 0 through 9
Two of those are worth pausing on. Ranging a string yields runes with their byte offsets, so i
jumps by more than one on a multi-byte character. Indexing str[i] gives a byte, not a character.
And ranging a map produces a different order on every run, by design, so any output that must be
stable needs the keys sorted first.
Discard what you do not need with _, or omit the second variable entirely:
for _, v := range slice { }
for i := range slice { } // index only
for range slice { } // neither — just repeat
The loop variable, before and after Go 1.22
This changed, and code written on either side of the change behaves differently:
for _, v := range items {
go func() {
fmt.Println(v) // Go < 1.22: probably the last item, repeatedly
}()
}
Before Go 1.22 the loop variable was one variable reused across iterations, so every goroutine
captured the same one and read whatever value it held when it ran. The fix was v := v at the top of
the body, and it appeared in a great deal of Go code.
Since Go 1.22, each iteration gets its own variable and the code above does what it looks like.
The behaviour follows the go directive in go.mod, not the toolchain version: a module declaring
go 1.21 still gets the old semantics on a new compiler, which is the detail that makes this
confusing in a mixed codebase.
The same applied to defer inside a loop, and to any closure outliving the iteration.
break, continue and labels
outer:
for i := range rows {
for j := range cols {
if done(i, j) {
break outer // leaves BOTH loops
}
if skip(i, j) {
continue outer // next i
}
}
}
An unlabelled break leaves only the innermost loop, which is why the flag variable exists in
languages without labels. break also applies to switch and select, which is why breaking out of
a loop from inside a switch requires a label: a plain break there ends the switch and the loop
carries on.
switch does not fall through
switch day {
case "sat", "sun":
fmt.Println("weekend")
case "mon":
fmt.Println("start of the week")
default:
fmt.Println("midweek")
}
No break at the end of each case: Go breaks implicitly. Several values in one case replace the
stacked labels other languages need.
fallthrough opts into the other behaviour, and it transfers to the next case unconditionally —
it does not re-evaluate that case’s condition:
case 1:
fmt.Println("one")
fallthrough
case 2:
fmt.Println("two") // runs for input 1 as well
It is rare, and its rarity is the point, the default is the safe one.
Expressionless switch
switch {
case score >= 90:
grade = "A"
case score >= 80:
grade = "B"
default:
grade = "F"
}
A switch with no subject evaluates each case as a boolean, in order. This is Go’s else if chain
and it reads better than one, the conditions line up and there is no trailing brace pile.
A type switch is the third form:
switch v := value.(type) {
case string:
fmt.Println(len(v)) // v is a string here
case int, int64:
fmt.Println(v) // v is still `any` — several types in one case
case nil:
fmt.Println("nil")
}
v is typed inside a single-type case and untyped where the case lists several. That asymmetry
surprises people; splitting the case is the fix.
defer, and where it runs
defer is not control flow in the loop sense and interacts with all of it:
func process(paths []string) error {
for _, p := range paths {
f, err := os.Open(p)
if err != nil {
return err
}
defer f.Close() // runs when process returns, NOT at the end of the iteration
// ...
}
return nil
}
Every deferred call accumulates until the function returns, so a loop over ten thousand paths holds ten thousand open files. The fix is to give the body its own function:
for _, p := range paths {
if err := func() error {
f, err := os.Open(p)
if err != nil {
return err
}
defer f.Close() // now runs per iteration
return handle(f)
}(); err != nil {
return err
}
}
Deferred calls run last-in-first-out, and their arguments are evaluated immediately —
defer fmt.Println(i) captures i at the point of the defer, not at the point it runs.
goto exists
goto is in the language and jumps within a function. It cannot jump over a variable declaration or
into a block, which removes its worst uses. It appears in generated code and in tight parsing loops;
in ordinary code a labelled break covers the same ground more legibly.
More in the Golang guides, including functions and interfaces.
Frequently asked questions
Does Go have a while loop?
No, for condition { } is the while form. for is the only loop
keyword and covers all four shapes.
Is there a ternary operator?
No. Use an if, or the min and max builtins added in Go 1.21 for
the common two-value case.
Why must the opening brace be on the same line?
Go inserts semicolons at line ends. A brace on the next line terminates the statement and leaves an empty block.
Do I need break at the end of a switch case?
No. Go breaks implicitly. fallthrough opts into
continuing, and it does not re-check the next case’s condition.
How do I match several values in one case?
Comma-separate them: case "sat", "sun":. In a type
switch, listing several types leaves the variable untyped in that case.
Why is my map iteration order different every run?
It is randomised deliberately, to stop code depending on an order the implementation does not guarantee. Collect and sort the keys for stable output.
Why does ranging a string skip indices?
It yields runes with their byte offsets, so a multi-byte character advances the index by more than one. Index the string directly to get bytes.
Why do my goroutines all print the last loop value?
On Go 1.21 and earlier the loop variable is
shared across iterations. Since 1.22 each iteration has its own, but the semantics follow the go
directive in go.mod, not the compiler.
How do I break out of nested loops?
Label the outer loop and use break outer. An unlabelled
break leaves only the innermost loop, or the enclosing switch, if there is one.
Should I ever use goto?
Rarely. It cannot jump over a declaration or into a block, so it is less
dangerous than elsewhere, but a labelled break is almost always clearer.