Notes · Learn Go › LESSON 3 OF 8

Control Flow

Go has exactly ONE loop keyword - for - and it does everything Python's while, for, and do-while do. Plus a superpower Python has no equivalent for: defer.

Contents
  1. if statements (with a twist)
  2. for: the ONLY loop
  3. range: iterating over things
  4. switch: smarter than C
  5. defer: the LIFO superpower
  6. Real-world examples
  7. Gotchas
  8. Glossary

if statements (with a twist)

Standard if/else if/else works exactly as you expect - just drop the parentheses around the condition and add braces:

Python
if x > 10:
    print("big")
elif x > 0:
    print("small")
else:
    print("zero or negative")
Go
if x > 10 {
    fmt.Println("big")
} else if x > 0 {
    fmt.Println("small")
} else {
    fmt.Println("zero or negative")
}

The if-with-init form

Go adds a syntax Python has no equivalent for. You can run a short statement before the condition, and the variable it declares is scoped to the entire if/else block:

if_init.go
// Standard form - val declared before, lives in outer scope
val, err := getValue()
if err != nil {
    log.Fatal(err)
}

// if-with-init - val and err scoped to this block only
if val, err := getValue(); err != nil {
    log.Fatal(err)
} else {
    fmt.Println("got:", val)
}
// val and err are gone here - out of scope

You see this constantly in Go code. It keeps error-handling variables from polluting the surrounding scope. The pattern is:

pattern.go
if <init statement>; <condition> {
    // body
}
Why this matters in Go: Most Go functions return (result, error). The if-with-init form lets you call the function, catch the error, and handle it - all without declaring throwaway variables that outlive the check. You will type this pattern dozens of times a day.
The 5-year-old version
Normally, to check if a door is locked, you need two steps: open the door, then check if it is locked.

The if-with-init form lets you do both in one breath: "open the door (init), then check if it is locked (condition), and if so, do something about it."

And the key you used to open the door? It disappears after you walk through. You can't accidentally use it somewhere else.

for: the ONLY loop

Go deleted while, do-while, and until. There is only for. But for has four distinct forms that cover all those cases:

C-STYLE for i := 0 ; i < 10 ; i++ { ... } Iterate N times. Python: range(10) i = 0, 1, 2 ... 9 WHILE-STYLE for x < 100 { x *= 2 } Loop while condition is true. Python: while x < 100 INFINITE for { ... break } Runs until break. Server loops, event loops, retry loops. Python: while True RANGE for i, v := range xs { ... } i = index, v = value. Works on slices, maps, strings, channels. Python: enumerate(xs)
One keyword, four moods. for is Go's only loop construct - it replaces while, do-while, until, and for from other languages.
Python - multiple loop keywords
# C-style (no direct equiv - use range)
for i in range(10):
    print(i)

# while loop
while x < 100:
    x *= 2

# infinite loop
while True:
    break

# for-each
for item in items:
    print(item)
Go - one loop keyword
// C-style
for i := 0; i < 10; i++ {
    fmt.Println(i)
}

// while-style
for x < 100 {
    x *= 2
}

// infinite loop
for {
    break
}

// range (for-each)
for _, item := range items {
    fmt.Println(item)
}
The 5-year-old version
One word, four moods. for is a shape-shifter.

It can count ("do this 10 times"), wait ("keep going while the light is red"), go forever ("spin forever until I say stop"), or walk through a list ("take each item from the bag").

Python gives you different words for each mood. Go says: just use for. We trust you to tell it what mood you want.
break and continue work exactly as in Python. break exits the loop; continue skips to the next iteration. Go also has labeled breaks for escaping nested loops - break outer - which Python lacks.

range: iterating over things

range is not a function - it is a language keyword that iterates over slices, maps, strings, and channels. It always returns up to two values. You choose which ones you want:

range_forms.go
xs := []string{"a", "b", "c"}

// index AND value
for i, v := range xs {
    fmt.Println(i, v) // 0 a, 1 b, 2 c
}

// just the index (ignore value)
for i := range xs {
    fmt.Println(i) // 0, 1, 2
}

// just the value (blank identifier discards index)
for _, v := range xs {
    fmt.Println(v) // a, b, c
}

// maps: key + value
m := map[string]int{"x": 1, "y": 2}
for k, v := range m {
    fmt.Printf("%s=%d\n", k, v)
}
Python - enumerate()
items = ["a", "b", "c"]

# index + value
for i, v in enumerate(items):
    print(i, v)

# just values
for v in items:
    print(v)

# dict key + value
for k, v in d.items():
    print(k, v)
Go - range keyword
items := []string{"a", "b", "c"}

// index + value
for i, v := range items {
    fmt.Println(i, v)
}

// just values
for _, v := range items {
    fmt.Println(v)
}

// map key + value
for k, v := range m {
    fmt.Printf("%s=%v\n", k, v)
}
What you range overFirst valueSecond value
slice or arrayindex (int)copy of element
mapkeyvalue
stringbyte index (int)rune (Unicode code point)
channelvalue received(no second value)
Map iteration order is randomized - intentionally.

Go deliberately randomizes map iteration order on every run. This prevents programs from accidentally relying on order that was never guaranteed. If you need stable order, collect the keys into a slice, sort it, then iterate the slice.

switch: smarter than C

Go's switch looks like C's but behaves much better. The key difference: no fallthrough by default. Each case breaks automatically. This is the opposite of C and Java.

if / else if chain if x == 1 false else if x == 2 false else if x == 3 false else Repetitive - checks x every time switch switch x case 1 case 2 default body A body B body C Evaluates x once, jumps directly to matching case
switch evaluates the expression once and jumps to the matching case. Cleaner than a long if/else if chain, and no accidental fallthrough.

Standard switch

switch.go
switch day {
case "Monday":
    fmt.Println("start of the week")
case "Friday":
    fmt.Println("almost there")
case "Saturday", "Sunday":    // multiple values per case
    fmt.Println("weekend")
default:
    fmt.Println("midweek")
}

Tagless switch (like if/else if)

Leave out the expression after switch and each case becomes an arbitrary boolean condition - a clean replacement for long if/else chains:

tagless_switch.go
switch {
case score >= 90:
    grade = "A"
case score >= 80:
    grade = "B"
case score >= 70:
    grade = "C"
default:
    grade = "F"
}

Type switch

Switch on the concrete type of an interface value - this is how Go does dynamic dispatch cleanly:

type_switch.go
func describe(i interface{}) {
    switch v := i.(type) {
    case int:
        fmt.Printf("int: %d\n", v)
    case string:
        fmt.Printf("string: %q\n", v)
    case bool:
        fmt.Printf("bool: %v\n", v)
    default:
        fmt.Printf("unknown: %T\n", v)
    }
}
Python - match (3.10+)
match status:
    case 200:
        result = "ok"
    case 404:
        result = "not found"
    case 500:
        result = "error"
    case _:
        result = "other"
Go - switch
switch status {
case 200:
    result = "ok"
case 404:
    result = "not found"
case 500:
    result = "error"
default:
    result = "other"
}
The 5-year-old version
Think of a vending machine. You pick your selection - B4, say. The machine goes straight to slot B4 and gives you your snack. Done. It does not accidentally also drop everything in B5 and B6.

C's switch is a broken vending machine. When you pick B4, it drops B4, then B5, then B6, until something says stop.

Go's switch is the working machine. Pick your selection, get your snack. No accidental snacks from the next slot.
Want fallthrough? You can opt in explicitly with the fallthrough keyword at the end of a case body. It transfers control to the next case unconditionally (even if that case's condition would not match). This is rare - most Go code never uses it.

defer: the LIFO superpower

defer schedules a function call to run when the enclosing function returns - no matter how it returns (normal return, error, panic). It is the most Go-specific concept in this lesson.

defer_basic.go
func readFile(name string) error {
    f, err := os.Open(name)
    if err != nil {
        return err
    }
    defer f.Close() // runs when readFile returns, always

    // ... do stuff with f ...
    // Close() is called automatically here
    return nil
}

You put the cleanup code right next to the thing being opened. No need to remember to close at every return point. No finally block required.

Multiple defers: LIFO order

When you defer multiple calls, they execute in last in, first out order - like popping a stack. The last thing you deferred runs first:

FUNCTION RUNS (defers push onto stack) defer fmt.Println ("first") pushed 1st defer fmt.Println ("second") pushed 2nd defer fmt.Println ("third") pushed 3rd DEFER STACK "third" (top) "second" "first" (bottom) TOP FUNCTION RETURNS (defers pop, LIFO) runs 1st: "third" runs 2nd: "second" runs 3rd: "first" Output: third second first pop pop pop
defer pushes calls onto a stack as the function runs. When the function returns, the stack unwinds in reverse - last deferred, first executed.

Common use cases

defer_uses.go
// 1. Close a file
f, _ := os.Open("data.txt")
defer f.Close()

// 2. Unlock a mutex
mu.Lock()
defer mu.Unlock()

// 3. Log how long a function took
start := time.Now()
defer func() {
    fmt.Println("elapsed:", time.Since(start))
}()

// 4. Recover from a panic (advanced)
defer func() {
    if r := recover(); r != nil {
        fmt.Println("recovered:", r)
    }
}()
Python - context manager
# Python's with block
with open("data.txt") as f:
    data = f.read()
# f is closed here automatically

# Can't defer arbitrary cleanup
# outside a with block
Go - defer
// Go's defer
f, _ := os.Open("data.txt")
defer f.Close() // any cleanup, anywhere
data, _ := io.ReadAll(f)
// f.Close() runs when function returns

// Works for any function call,
// not just resource managers
The 5-year-old version
defer is like writing a sticky note that says "remember to do this before you leave the room."

You can leave as many notes as you want. When you finally leave - whether you walk out normally or run out because of an emergency - all the notes get read. And they are read in reverse order: the most recent note first.

This is really useful for cleanup work. Open a file? Leave a note: "close the file." Lock a door? Leave a note: "unlock the door." You never forget, and you always do it in the right order.
defer arguments are evaluated immediately, not at call time.

defer fmt.Println(x) captures the current value of x right now - not the value x has when the function returns. If you need the final value, use a closure: defer func() { fmt.Println(x) }().

Real-world examples

The control flow you just learned, applied to three real contexts:

BackendHTTP handler using switch on method

A single handler function that dispatches to different logic based on the HTTP method:

handler.go
func itemHandler(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case http.MethodGet:
        // list or fetch item
        id := r.URL.Query().Get("id")
        if item, err := store.Get(id); err != nil {
            http.Error(w, err.Error(), http.StatusNotFound)
        } else {
            json.NewEncoder(w).Encode(item)
        }
    case http.MethodPost:
        // create item
        var item Item
        if err := json.NewDecoder(r.Body).Decode(&item); err != nil {
            http.Error(w, "bad request", http.StatusBadRequest)
            return
        }
        store.Put(item)
        w.WriteHeader(http.StatusCreated)
    case http.MethodDelete:
        id := r.URL.Query().Get("id")
        store.Delete(id)
    default:
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
    }
}

Notice: switch r.Method is cleaner than a chain of if r.Method == "GET". The if-with-init form handles the error from store.Get without polluting the outer scope.

CLIProcessing input files with defer for cleanup

A CLI tool that processes every file passed as an argument, deferring close so cleanup is guaranteed:

process.go
func processFiles(paths []string) error {
    for _, path := range paths {
        if err := processOne(path); err != nil {
            fmt.Fprintf(os.Stderr, "error processing %s: %v\n", path, err)
            continue // skip bad files, process the rest
        }
    }
    return nil
}

func processOne(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close() // guaranteed cleanup, even on error return below

    scanner := bufio.NewScanner(f)
    for scanner.Scan() {
        line := scanner.Text()
        // process each line ...
        fmt.Println(line)
    }
    return scanner.Err()
}

The outer loop uses continue to skip bad files gracefully. Each file is opened in its own function so defer f.Close() fires when that function returns - not at the end of the whole loop.

k8sReconcile loop (preview)

Kubernetes controllers use an infinite loop combined with select to watch for events. Here is a simplified skeleton - we will cover select and channels in the concurrency lesson:

controller.go
func (c *Controller) Run(stopCh <-chan struct{}) {
    defer c.queue.ShutDown() // clean up queue on exit

    for {
        // select lets you wait on multiple channels at once
        // (covered in the concurrency lesson)
        item, quit := c.queue.Get()
        if quit {
            return // stopCh was closed - exit the loop
        }
        if err := c.reconcile(item.(string)); err != nil {
            c.queue.AddRateLimited(item) // retry on error
        }
        c.queue.Done(item)
    }
}

The infinite for loop is the heartbeat of every Kubernetes controller. defer c.queue.ShutDown() ensures the work queue is cleaned up whether the controller exits normally or panics. This pattern runs inside every pod of kube-controller-manager.

Gotchas (read twice)

1. No while keyword - use for condition { }

Coming from Python, your muscle memory will type while. Go won't compile it. Translate every while x: to for x {. The shape is the same; only the keyword changes.

2. Map iteration order is randomized - never rely on it

Go deliberately randomizes map iteration on every program run. If you need ordered output from a map, collect keys with for k := range m, sort the slice with sort.Strings(keys), then iterate the slice.

3. defer arguments are evaluated immediately

defer fmt.Println(x) captures the current value of x right now, not when the function returns. If x is 1 at the defer line and 99 at return, you will print 1. To capture the final value, use a closure: defer func() { fmt.Println(x) }()

4. switch does NOT fallthrough by default - opposite of C

If you have C or Java experience, you will be surprised that Go cases break automatically. This is almost always what you want. If you really need fallthrough, use the fallthrough keyword explicitly - but you will rarely need it.

5. if-with-init scopes the variable to the if/else block only

In if val, err := f(); err != nil { ... }, both val and err are scoped to the if and else blocks. You cannot access them outside. This is a feature - it prevents these short-lived error variables from polluting the enclosing function scope - but it can surprise you when you try to use val after the block.

Glossary

TermMeaning
ifConditional statement. No parentheses around the condition. Opening brace must be on the same line.
elseAlternate branch. Must appear on the same line as the closing } of the preceding if.
if-with-initThe form if stmt; cond { } - runs a short statement before evaluating the condition. Variables from the init are scoped to the if/else block.
forGo's only loop keyword. Covers C-style, while-style, infinite, and range-based loops.
rangeKeyword that iterates over slices, arrays, maps, strings, and channels. Returns index+value (or key+value for maps).
switchMulti-way branch statement. No fallthrough by default. Supports multiple values per case, tagless form, and type switch.
caseA branch inside a switch. Breaks automatically at the end unless fallthrough is used.
defaultThe catch-all branch in a switch, executed when no other case matches.
fallthroughKeyword that explicitly transfers control to the next case in a switch. Rare in idiomatic Go.
breakExits the innermost for loop or switch. Can be labeled (break outer) to exit nested loops.
continueSkips the rest of the current loop iteration and starts the next one.
deferSchedules a function call to run when the enclosing function returns. Multiple defers execute in LIFO order.
LIFOLast In, First Out - the order in which deferred calls run. The most recently deferred function runs first.
Type switchA switch that inspects the dynamic type of an interface value: switch v := x.(type) { case int: ... }
Tagless switchA switch with no expression after the keyword: switch { case cond1: ... }. Each case is an arbitrary boolean. Equivalent to an if/else if chain.

Origins, gotchas, and a quick check

Backstory and pitfalls that make this chapter stick.

What's in a name?
for
Go's only loop construct. No while, no do-while, no until. for cond {} IS while. for {} is infinite loop.
if
A statement, not an expression. No ternary ? : in Go. Can have an init statement: if x, err := foo(); err != nil { ... }.
switch
Cases evaluated top-to-bottom, no implicit fall-through. case can be expressions, not just values.
fallthrough
Explicit keyword to opt-IN to C-style case fall-through. Almost never used in idiomatic Go.
range
Iterator over slices/maps/channels/strings. Returns (index, value) for slices, (key, value) for maps, (byte-index, rune) for strings.
break / continue
Same as C/Python. Labeled break exists for nested loops: break outerLoop.
goto
Yes, Go has goto. Only used in generated code and a few stdlib hot paths. Don't use it in normal code.
Fun fact Go has no while loop because Rob Pike and Ken Thompson decided one looping construct was enough. for does everything: for cond { ... } is while, for { ... } is while(true), for i := 0; i < n; i++ { ... } is C-style. The decision was made for syntactic minimalism. Every Go program reads "for", not "for or while or do-while" - one keyword to learn.
Bug hunt

What does this program print?

defer-loop.go
package main

import "fmt"

func main() {
    for i := 0; i < 5; i++ {
        defer fmt.Println(i)
    }
    fmt.Println("done")
}
Click to reveal the bug

Output:

output
done
4
3
2
1
0

defer is per-function, not per-loop-iteration. All 5 defers are scheduled, then executed in LIFO order after done prints (when main returns).

In a tight loop with millions of iterations, the deferred calls accumulate before any run - a memory leak waiting to happen. Move the work into a helper function or anonymous IIFE:

fix.go
for i := 0; i < 5; i++ {
    func() {
        defer fmt.Println(i)
        // work...
    }()
}
Real-world incident The Go-pre-1.22 range loop variable capture bug 15 years of bugs in production

This pattern burned countless Go engineers from 2009 to 2024:

capture.go
var fns []func()
for _, v := range items {
    fns = append(fns, func() { fmt.Println(v) })
}
for _, fn := range fns { fn() }

This printed the SAME value (the last item) for every closure. Why? Before Go 1.22, the loop variable v was shared across iterations. Production incidents from this bug include Kubernetes controller bugs, monitoring miscounts, and at least one major fintech alert system that fired the same alert N times instead of N different alerts.

Go 1.22 (Feb 2024) fixed it by per-iteration scoping the loop variable - a rare backwards-incompatible change justified by how often this bit people.

Lesson: If you're on Go 1.22+, you're safe. If you're on older Go, pin the variable inside the loop body: v := v (the famous shadowing line). This single design decision was probably Go's most painful gotcha for over a decade.

Quick check

Test yourself

Don't peek. Think first, then click to reveal each answer.

1. Go's switch falls through to the next case by default.

  • True
  • False
Show answer
Answer: b. False. Unlike C, Go switch cases break automatically. Use the explicit fallthrough keyword to opt-in. This is Go reversing C's default - which is the right call (most C bugs around switch are forgotten breaks).

2. What does for i, v := range []string{"a", "b"} {} yield?

  • 0 a then 1 b
  • a 0 then b 1
  • a then b
  • 0 then 1
Show answer
Answer: a. Range over a slice yields (index, value). Use _ to discard either: for _, v := range items for value-only.

3. Go has which loop keywords?

  • for only
  • for and while
  • for, while, do-while
  • loop
Show answer
Answer: a. for is the only loop construct. Three forms: for cond (while), for (infinite), for init; cond; post (C-style).

4. case x, y, z: in a switch statement matches multiple values.

  • True
  • False
Show answer
Answer: a. True. Comma-separated values are how you OR cases. case 1, 3, 5: fmt.Println("odd").

5. for { fmt.Println("hi") } does what?

  • Syntax error
  • Prints "hi" once
  • Infinite loop
  • Loops while a built-in counter is positive
Show answer
Answer: c. Bare for with no condition is while(true). Exit with break or return.