Learn Go Series (#4) - Control Flow: if, switch, and the Only Loop
Learn Go Series (#4) - Control Flow: if, switch, and the Only Loop

What will I learn
- The
ifstatement and its handy init clause that scopes a variable to the branch; - Go's single loop keyword
forin all of its shapes, includingfor rangeand the newer range-over-an-integer form; - The
switch-- no fallthrough by default, multiple values per case, an optional init statement, and the "tagless"switch trueform; - A first look at the type switch, and
break/continuewith labels for nested loops; - Why Go has exactly one loop and no ternary operator, and why that reads better in the large;
- When
gotois (rarely) the right call.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Go distribution (1.27 or newer, from go.dev/dl) -- tested against Go 1.27;
- Episodes 1-3, or comfort with functions and multiple returns;
- The ambition to learn Go programming.
Difficulty
- Beginner
Curriculum (of the Learn Go Series):
- Learn Go Series (#1) - Introduction to Go
- Learn Go Series (#2) - Variables, Types, Constants, and iota
- Learn Go Series (#3) - Functions and First-Class Functions
- Learn Go Series (#4) - Control Flow: if, switch, and the Only Loop (this post)
Learn Go Series (#4) - Control Flow: if, switch, and the Only Loop
Control flow is where Go's "fewer things, used more ways" instinct is at its clearest. There is one loop keyword, not three. There is no ternary operator. The switch does the job of long if/else chains far more cleanly than you might expect, and it does a couple of things C's switch cannot. None of this is a limitation once you see the shapes -- it is a smaller set of tools that happen to cover more ground per tool. That is a recurring theme in Go, and today is the episode where it stops being a slogan and starts being something you can feel while typing. Let us clear last episode's exercises and then walk through each construct in turn.
Solutions to Episode 3 Exercises
Exercise 1 -- safe division. The (result, error) shape, this time with floats:
package main
import (
"errors"
"fmt"
)
func safeDiv(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("cannot divide by zero")
}
return a / b, nil
}
func main() {
if q, err := safeDiv(9, 2); err == nil {
fmt.Printf("9 / 2 = %.2f\n", q)
}
if _, err := safeDiv(1, 0); err != nil {
fmt.Println("caught:", err)
}
}
The key insight from episode 3 carried straight in here: a failure is just a value you return and the caller checks, and the if with an init clause (which we cover properly below) is the idiomatic place to do that checking.
Exercise 2 -- a variadic average. Guard the empty case so it never divides by zero:
package main
import "fmt"
func average(nums ...float64) float64 {
if len(nums) == 0 {
return 0
}
total := 0.0
for _, n := range nums {
total += n
}
return total / float64(len(nums))
}
func main() {
fmt.Printf("%.2f\n", average(2, 4, 6, 8))
fmt.Printf("%.2f\n", average()) // 0.00, no panic
}
The if len(nums) == 0 guard is the whole point -- a variadic can be called with zero arguments, so a naive divide would blow up on the empty case. Handle the awkward input first, then let the happy path run flat underneath it.
Exercise 3 -- an adder factory. The closure captures a running total and adds n * step on each call, so each adder accumulates independently:
package main
import "fmt"
func adder(step int) func(int) int {
total := 0
return func(n int) int {
total += n * step
return total
}
}
func main() {
byTwos := adder(2)
fmt.Println(byTwos(1), byTwos(1), byTwos(1)) // 2 4 6
byFives := adder(5)
fmt.Println(byFives(1)) // 5, a separate total
}
Each call to adder makes a fresh total, and the returned function captures that specific variable -- so byTwos and byFives never touch each other's total. That is the same closure mechanic we spent real time on last episode, now doing something practical. On to control flow.
if, and its init clause
An if in Go needs no parentheses around the condition -- the braces are mandatory instead, and gofmt puts the opening brace on the same line, so the shape is fixed and there is nothing to argue about. What is genuinely useful is that an if can carry a short init statement before the condition, separated by a semicolon. The variable it declares is scoped to the if/else -- it does not leak into the surrounding function. This is the idiomatic home of the error check:
package main
import (
"fmt"
"strconv"
)
func main() {
// strconv.Atoi parses a string to an int, returning (int, error)
if n, err := strconv.Atoi("42"); err == nil {
fmt.Println("parsed:", n*2)
} else {
fmt.Println("not a number")
}
// n is not visible here -- it belonged to the if statement
grade := 82
if grade >= 90 {
fmt.Println("A")
} else if grade >= 80 {
fmt.Println("B")
} else {
fmt.Println("C or below")
}
}
Scoping the parsed value to the branch that checks it keeps the surrounding function clean, and -- more importantly -- it stops you accidentally using a value that might be invalid. If strconv.Atoi fails, n is meaningless, and Go's scoping makes n simply unavailable outside the branch where you have already confirmed err == nil. That is not a coding-style suggestion enforced by a linter, it is the language itself keeping a dubious value out of reach. You will write this exact if x, err := something(); err != nil line thousands of times, so it is worth having in your fingers early.
And note there is no ternary ?: in Go. If you came from C or JavaScript you will reach for cond ? a : b and it simply will not be there. An if/else is the whole story:
package main
import "fmt"
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
func main() {
fmt.Println(abs(-7), abs(7)) // 7 7
}
The Go authors dropped the ternary on purpose. Their argument (and I have come around to it) is that a ternary is lovely for max(a, b) and awful the moment the branches grow -- people nest them, chain them, and the result is a puzzle. Forcing everything through if/else costs you a couple of lines in the trivial case and saves you from unreadable one-liners in every non-trivial one. It is the same trade Go makes over and over: give up a little brevity, buy back a lot of the-code-reads-the-same-everywhere.
The only loop: for in its several shapes
Go has one loop keyword, for, and it wears several hats. The classic three-clause form, a condition-only form (the while you do not have), an infinite form, and the for range that walks collections:
package main
import "fmt"
func main() {
// 1. three-clause
for i := 0; i < 3; i++ {
fmt.Print(i, " ")
}
fmt.Println()
// 2. condition-only (a "while")
n := 1
for n < 100 {
n *= 2
}
fmt.Println("first power of two >= 100:", n)
// 3. infinite, with an explicit break
count := 0
for {
count++
if count == 5 {
break
}
}
fmt.Println("counted to", count)
// 4. range over a slice: index and value
for i, letter := range []string{"a", "b", "c"} {
fmt.Printf("%d:%s ", i, letter)
}
fmt.Println()
}
That is four different loops from one keyword. If you only want the value from a range and not the index, you write for _, v := range xs and drop the index into the blank identifier we met in episode 3; if you only want the index, for i := range xs. One tool, several grips.
There is a fifth grip that landed recently and is genuinely handy: as of Go 1.22 you can range over an integer to count from zero. It reads cleaner than the three-clause form when all you want is "do this N times":
package main
import "fmt"
func main() {
// range over an int: i goes 0, 1, 2, 3, 4
for i := range 5 {
fmt.Print(i, " ")
}
fmt.Println()
// and if you do not even need i, just count:
sum := 0
for range 3 {
sum += 10
}
fmt.Println("sum:", sum) // 30
}
for i := range 5 is exactly for i := 0; i < 5; i++, only shorter and harder to get wrong (no off-by-one in the bound, no stray i++). And for range 3 with no variables at all is the tidy way to say "repeat this three times" -- something that used to need a throwaway counter.
One thing about range that surprises newcomers, so let me plant the flag now: the value range hands you is a copy. In for _, v := range xs, v is a fresh copy of each element, so assigning to v does not change the slice. If you want to mutate the underlying element you index it directly with xs[i]. We will come back to this properly when we do slices, because it is the source of a classic "why did my change not stick?" moment. Ranging over a string is its own small adventure too (you get byte offsets and decoded runes, not plain characters), but that belongs to the strings-and-runes episode -- for now just file away that range copies, and that strings are not quite what they look like.
switch: cleaner than a chain of ifs
Go's switch does not fall through to the next case by default -- each case stands alone, so you almost never write break. A case can match several values, and a "tagless" switch (switch with no expression) is just a clean way to write an if/else if ladder:
package main
import "fmt"
func classify(n int) string {
switch {
case n < 0:
return "negative"
case n == 0:
return "zero"
case n < 10:
return "small"
default:
return "large"
}
}
func main() {
for _, n := range []int{-3, 0, 7, 42} {
fmt.Printf("%d is %s\n", n, classify(n))
}
// a switch on a value, with multiple values per case
day := "Sat"
switch day {
case "Sat", "Sun":
fmt.Println("weekend")
default:
fmt.Println("weekday")
}
}
The tagless switch { case cond: ... } reads as a tidy ladder of conditions -- often nicer than if/else if/else, because each condition lines up under the last. And case "Sat", "Sun": matches either value without repeating the body, which is a small thing that adds up when your cases get long.
This no-fallthrough default is worth pausing on, because it is a deliberate reversal of C. In C (and its many descendants) a switch case falls into the next one unless you remember to write break, and forgetting that break has caused a genuinely huge number of bugs over the decades. Go flips the default: cases are independent, and if you genuinely want to fall through to the next case, you ask for it with an explicit fallthrough keyword, so it can never happen by accident:
package main
import "fmt"
func main() {
switch n := 2; n {
case 2:
fmt.Println("two")
fallthrough // explicitly continue into the next case
case 1:
fmt.Println("one")
case 0:
fmt.Println("zero") // NOT reached: fallthrough goes to the *next* case only
}
}
Two things to notice in that snippet. First, switch n := 2; n { uses a switch init statement -- just like if, a switch can declare a variable scoped to the whole switch, which is perfect for "compute a thing, then branch on it" without leaking that thing into the surrounding function. Second, fallthrough transfers to the immediately following case and does so unconditionally -- it does not re-test the next case's condition, it just runs its body. In practice you will use fallthrough rarely (I reach for it maybe once a year), but when you want it, it is right there and it is explicit. That is the whole trade: the common case (no fallthrough) is free, and the rare case (fallthrough) is one honest keyword.
The type switch
Because Go has interfaces (a whole later episode), you sometimes hold a value whose concrete type you do not yet know -- an any. A type switch branches on that concrete type and hands you the value already converted. Here is the shape, using any (the empty interface that any value satisfies):
package main
import "fmt"
func describe(v any) string {
switch x := v.(type) {
case int:
return fmt.Sprintf("an int: %d", x)
case string:
return fmt.Sprintf("a string of length %d", len(x))
case bool:
return fmt.Sprintf("a bool: %t", x)
default:
return fmt.Sprintf("some other type: %T", x)
}
}
func main() {
fmt.Println(describe(42))
fmt.Println(describe("hello"))
fmt.Println(describe(3.14))
}
v.(type) is special syntax that only works inside a switch, and within each case x already has the matched type -- an int in the int case, a string in the string case, so you can call len(x) in the string case without any extra conversion. This is the multi-case sibling of the single type assertion x, ok := v.(int), which uses that same comma-ok shape we saw on maps last episode. When you only expect one type, you assert; when you want to branch across several, you type-switch. We lean on this hard when we reach interfaces -- for now, just recognise the shape and note the %T verb in the default case, which prints the dynamic type of whatever came in (a handy little debugging trick in its own right).
Labels: break and continue across nested loops
A plain break or continue affects the innermost loop. When you need to break out of an outer loop from inside an inner one, you attach a label and break to it. This is the clean alternative to a "found" flag that you would otherwise have to declare, set, and re-check:
package main
import "fmt"
func main() {
grid := [][]int{
{1, 2, 3},
{4, 5, 6},
{7, 8, 9},
}
target := 5
search:
for r, row := range grid {
for c, v := range row {
if v == target {
fmt.Printf("found %d at row %d, col %d\n", target, r, c)
break search // breaks the OUTER loop, not just the inner
}
}
}
}
The label search: names the outer loop, and break search jumps out of it entirely -- inner loop and outer loop, gone in one statement. Without the label, that inner break would only escape the inner for, leaving the outer loop to keep grinding through the remaining rows for no reason.
continue takes a label too, and it means something slightly different: skip to the next iteration of the labelled loop. That is exactly what you want when the inner loop decides "this whole outer item is done, move to the next one":
package main
import "fmt"
func main() {
rows := [][]int{
{1, 2, 3},
{4, -1, 6}, // contains a negative
{7, 8, 9},
}
rows:
for i, row := range rows {
for _, v := range row {
if v < 0 {
fmt.Printf("row %d has a negative, skipping it\n", i)
continue rows // jump to the next row, abandoning this one
}
}
fmt.Printf("row %d is all non-negative\n", i)
}
}
Here continue rows abandons the current row the instant it spots a negative and moves the outer loop forward, so the "all non-negative" line only prints for clean rows. Go also has goto for the rare cases where a state machine reads more clearly as explicit jumps (parsers and lexers sometimes qualify), but you will reach for labelled break/continue far more often, and for almost everything else a well-shaped loop or switch beats either. I have written quit some Go over the years and used goto a handful of times total -- it exists, it is occasionally the honest choice, and it is not something to go looking for.
Why one loop, and no ternary
It is worth stepping back to see the shape of these decisions together, because they rhyme. One loop keyword instead of while/do/for. No ternary. A switch that will not fall through unless you insist. Each of these removes a way to write the same idea, and in exchange every Go codebase you ever open loops, branches, and switches the same way. When you have read a few hundred thousand lines of other people's Go (which you will, if you work in it), that uniformity stops being a constraint and starts being a superpower: there is far less dialect to decode, because the language quietly refused to offer the dialect in the first place. Having said that, Go is not being minimalist for its own sake -- it kept switch powerful, gave for its range forms, and added the init clause to both if and switch. The rule is not "fewer features", it is "fewer redundant features".
The same idea in Python
Python has while, for, and (recently) match, plus a ternary expression. Go folds while into for, has no ternary, and its switch does not fall through -- so a Go switch behaves more like Python's match than like C's switch:
def classify(n):
if n < 0:
return "negative"
elif n == 0:
return "zero"
elif n < 10:
return "small"
return "large"
for n in (-3, 0, 7, 42):
print(n, classify(n))
Python's for iterates any iterable directly; Go's for range does the same over slices, maps, strings and channels (and, since 1.22, over an integer count). Where Python offers a if cond else b as a ternary expression, Go asks you to spell out the if/else -- more lines, but the same shape whether the branches are one token or twenty. The big mental shift is small: one loop keyword, a switch you can trust not to fall through, and no clever one-line conditional to reach for. Learning to not miss the ternary is genuinely part of getting comfortable in Go.
Exercises
FizzBuzz, the Go way. Print the numbers 1 to 30, but for multiples of 3 print
Fizz, for multiples of 5 printBuzz, and for multiples of both printFizzBuzz. Use aforloop and a taglessswitch(switch { case ...: }) rather than a chain of ifs.Parse-or-skip. Given a slice of strings like
[]string{"12", "x", "7", "-4"}, loop over it and sum only the ones that parse as integers, usingstrconv.Atoiinside anifwith an init clause. Print the running total and skip the rest withcontinue.First match in a grid. Write a function that takes a
[][]intand a target value, and returns the row and column of the first occurrence (and a found boolean), using a labelledbreakto leave both loops at once. Return-1, -1, falsewhen it is not present.
What we learned
ifneeds no parentheses and can carry an init statement whose variable is scoped to the branch -- the idiomatic home of the error check, keeping a dubious value out of reach where it is not valid;- Go has exactly one loop keyword,
for, in several shapes: three-clause, condition-only (thewhile), infinite (for {}),for rangeover collections, and (since 1.22)for range nover an integer count -- and remember thatrangehands you a copy of each value; switchdoes not fall through by default (the opposite of C), a case can list several values, it can carry its own init statement, and a taglessswitch { case cond: }is a cleanif/else ifladder;fallthroughopts into the old behaviour explicitly;- A type switch (
v.(type)) branches on a value's concrete type and hands you the converted value in each case -- the multi-case sibling of the comma-ok type assertion; - Labels let
break/continueact on an outer loop, the clean way in and out of nested loops;gotoexists for the rare state-machine case; - There is no ternary operator -- an
if/elseis the whole story, and the whole family of "one way to do it" choices buys a uniformity that pays off at scale.
Next episode we meet the collection you will use more than any other and the one with the most interesting internals: arrays, slices, and the aliasing gotcha that surprises everyone exactly once (the same "range copies, slices share" thread I kept teasing today). Bring the editor, there is real code to write. See you there.
Tot de volgende keer! ;-)
go looks interesting :D