Skip to content
CalliCoder

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 immediatelydefer 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.