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.
Standard if/else if/else works exactly as you expect - just drop the parentheses around the condition and add braces:
if x > 10:
print("big")
elif x > 0:
print("small")
else:
print("zero or negative")
if x > 10 {
fmt.Println("big")
} else if x > 0 {
fmt.Println("small")
} else {
fmt.Println("zero or negative")
}
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:
// 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:
if <init statement>; <condition> {
// body
}
(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.
Go deleted while, do-while, and until. There is only for. But for has four distinct forms that cover all those cases:
for is Go's only loop construct - it replaces while, do-while, until, and for from other languages.# 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)
// 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)
}
for is a shape-shifter.
for. We trust you to tell it what mood you want.
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 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:
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)
}
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)
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 over | First value | Second value |
|---|---|---|
slice or array | index (int) | copy of element |
map | key | value |
string | byte index (int) | rune (Unicode code point) |
channel | value received | (no second value) |
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.
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.
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")
}
Leave out the expression after switch and each case becomes an arbitrary boolean condition - a clean replacement for long if/else chains:
switch {
case score >= 90:
grade = "A"
case score >= 80:
grade = "B"
case score >= 70:
grade = "C"
default:
grade = "F"
}
Switch on the concrete type of an interface value - this is how Go does dynamic dispatch cleanly:
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)
}
}
match status:
case 200:
result = "ok"
case 404:
result = "not found"
case 500:
result = "error"
case _:
result = "other"
switch status {
case 200:
result = "ok"
case 404:
result = "not found"
case 500:
result = "error"
default:
result = "other"
}
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 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.
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.
When you defer multiple calls, they execute in last in, first out order - like popping a stack. The last thing you deferred runs first:
// 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'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'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
defer is like writing a sticky note that says "remember to do this before you leave the room."
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) }().
The control flow you just learned, applied to three real contexts:
A single handler function that dispatches to different logic based on the HTTP method:
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.
A CLI tool that processes every file passed as an argument, deferring close so cleanup is guaranteed:
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.
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:
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.
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.
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.
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) }()
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.
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.
| Term | Meaning |
|---|---|
if | Conditional statement. No parentheses around the condition. Opening brace must be on the same line. |
else | Alternate branch. Must appear on the same line as the closing } of the preceding if. |
| if-with-init | The form if stmt; cond { } - runs a short statement before evaluating the condition. Variables from the init are scoped to the if/else block. |
for | Go's only loop keyword. Covers C-style, while-style, infinite, and range-based loops. |
range | Keyword that iterates over slices, arrays, maps, strings, and channels. Returns index+value (or key+value for maps). |
switch | Multi-way branch statement. No fallthrough by default. Supports multiple values per case, tagless form, and type switch. |
case | A branch inside a switch. Breaks automatically at the end unless fallthrough is used. |
default | The catch-all branch in a switch, executed when no other case matches. |
fallthrough | Keyword that explicitly transfers control to the next case in a switch. Rare in idiomatic Go. |
break | Exits the innermost for loop or switch. Can be labeled (break outer) to exit nested loops. |
continue | Skips the rest of the current loop iteration and starts the next one. |
defer | Schedules a function call to run when the enclosing function returns. Multiple defers execute in LIFO order. |
| LIFO | Last In, First Out - the order in which deferred calls run. The most recently deferred function runs first. |
| Type switch | A switch that inspects the dynamic type of an interface value: switch v := x.(type) { case int: ... } |
| Tagless switch | A switch with no expression after the keyword: switch { case cond1: ... }. Each case is an arbitrary boolean. Equivalent to an if/else if chain. |
Backstory and pitfalls that make this chapter stick.
for cond {} IS while. for {} is infinite loop.? : in Go. Can have an init statement: if x, err := foo(); err != nil { ... }.case can be expressions, not just values.(index, value) for slices, (key, value) for maps, (byte-index, rune) for strings.break outerLoop.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.
What does this program print?
package main
import "fmt"
func main() {
for i := 0; i < 5; i++ {
defer fmt.Println(i)
}
fmt.Println("done")
}Output:
done
4
3
2
1
0defer 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:
for i := 0; i < 5; i++ {
func() {
defer fmt.Println(i)
// work...
}()
}This pattern burned countless Go engineers from 2009 to 2024:
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.
Don't peek. Think first, then click to reveal each answer.
1. Go's switch falls through to the next case by default.
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 ba 0 then b 1a then b0 then 1(index, value). Use _ to discard either: for _, v := range items for value-only.
3. Go has which loop keywords?
for onlyfor and whilefor, while, do-whileloopfor 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.
case 1, 3, 5: fmt.Println("odd").
5. for { fmt.Println("hi") } does what?
for with no condition is while(true). Exit with break or return.