Skip to content

Control Flow

Go’s if statements do not require parentheses but do require braces. The condition can include an Initialization statement.

if x > 0 {
fmt.Println("positive")
} else if x < 0 {
fmt.Println("negative")
} else {
fmt.Println("zero")
}

A common pattern is to initialize a variable in the if scope:

if err := doSomething(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
return
}
// err is not accessible here -- scoped to the if block

This keeps the variable scoped to where it is needed and avoids polluting the enclosing function Scope.

The initialization statement pattern is the idiomatic way to check errors:

if val, err := compute(); err != nil {
log.Fatal(err)
} else {
fmt.Println(val)
}

The else is on the same line as the closing brace of the if block. This is enforced by gofmt.

Go’s switch is more flexible than C’s. Cases break automatically — no fallthrough unless Explicitly requested.

switch os := runtime.GOOS; os {
case "darwin":
fmt.Println("macOS")
case "linux":
fmt.Println("Linux")
default:
fmt.Println("Other")
}

A switch with no condition is equivalent to switch true. This is an idiomatic replacement for Long if-else chains:

switch {
case x < 0:
fmt.Println("negative")
case x == 0:
fmt.Println("zero")
case x > 0:
fmt.Println("positive")
}

A type switch compares types rather than values. It is used with interface values:

func inspect(i interface{}) {
switch v := i.(type) {
case int:
fmt.Printf("integer: %d\n", v)
case string:
fmt.Printf("string: %s\n", v)
case bool:
fmt.Printf("boolean: %t\n", v)
default:
fmt.Printf("unknown type: %T\n", v)
}
}

The variable v has the type of the matched case, not the interface type.

Use fallthrough to execute the next case unconditionally. It is rarely needed:

switch n := 5; n {
case 5:
fmt.Println("five")
fallthrough
case 4:
fmt.Println("four or five")
}
// Output:
// five
// four or five

Go has exactly one looping construct: for. There is no whileNo do-whileNo loop.

for i := 0; i < 10; i++ {
fmt.Println(i)
}

Omitting all three components creates an infinite loop:

for {
// runs forever
}

Omitting only the init and post creates a while loop:

n := 0
for n < 10 {
n++
}

The range clause iterates over slices, arrays, maps, strings, and channels.

nums := []int{10, 20, 30}
for i, v := range nums {
fmt.Printf("index %d: value %d\n", i, v)
}

Use _ to discard the index (common when only values are needed):

for _, v := range nums {
fmt.Println(v)
}

Iteration order over maps is not guaranteed and varies between runs:

m := map[string]int{"a": 1, "b": 2, "c": 3}
for k, v := range m {
fmt.Printf("%s: %d\n", k, v)
}

Iterates over Unicode code points (runes), not bytes:

s := "hello"
for i, r := range s {
fmt.Printf("byte offset %d: rune %c\n", i, r)
}

Receives values from a channel until it is closed:

ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)
for v := range ch {
fmt.Println(v) // 1, 2, 3
}

range copies the value for each iteration. For slices and arrays of pointers or structs, this Means you get a copy of the element, not a reference:

items := []struct{ X int }{{1}, {2}, {3}}
for _, item := range items {
item.X *= 2 // modifies a copy, not items[i]
}
fmt.Println(items) // [{1} {2} {3}] -- unchanged

To modify elements, use the index:

for i := range items {
items[i].X *= 2
}
fmt.Println(items) // [{2} {4} {6}]

For pointers, the copy is still a pointer but dereferences the same underlying data:

items := []*int{new(int), new(int), new(int)}
*items[0] = 1
for _, p := range items {
*p *= 2 // modifies the pointed-to value
}

break exits the innermost for``switchOr select. continue skips to the next iteration of The innermost for loop.

Break out of an outer loop using a label:

outer:
for i := 0; i < 5; i++ {
for j := 0; j < 5; j++ {
if i*j == 6 {
break outer
}
}
}
fmt.Println("broke at i*j == 6")

Skip to the next iteration of an outer loop:

outer:
for i := 0; i < 3; i++ {
for j := 0; j < 3; j++ {
if j == 1 {
continue outer
}
fmt.Printf("%d %d\n", i, j)
}
}
// Output:
// 0 0
// 1 0
// 2 0

Go supports goto with restrictions. The label must be defined in the same function, and goto Cannot jump over variable declarations or into inner blocks.

func process(items []int) {
for _, item := range items {
if item < 0 {
goto invalid
}
fmt.Println(item)
}
return
invalid:
fmt.Println("found negative item")
}

goto is rarely used in idiomatic Go. Prefer structured control flow (for``ifFunctions).

defer schedules a function call to run when the surrounding function returns. Arguments are Evaluated immediately, but the function call is deferred.

func readConfig(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(f)
}

Deferred functions execute in LIFO (last-in, first-out) order:

func main() {
defer fmt.Println("first")
defer fmt.Println("second")
defer fmt.Println("third")
// Output:
// third
// second
// first
}

A deferred function can read and modify named return values:

func double(x int) (result int) {
defer func() {
result *= 2
}()
result = x
return // returns x * 2
}
fmt.Println(double(5)) // 10

This works because return first assigns the value to the named return variable, then deferred Functions execute.

Control flow is a choose-your-own-adventure book: Every if, switch, and for is a decision point where the program picks a path. Go’s design — mandatory braces, auto-breaking switch cases, defer running on the way out — is like giving the reader guardrails so they never accidentally skip a page or re-read one twice.

Why it matters: The defer statement is like a sticky note you leave on your desk: “when you leave the office, do this before you go.” It guarantees cleanup happens exactly once, in reverse order, no matter how the function exits — even on panic.

The key insight: Go’s control flow is deliberately simple (one loop keyword, no fallthrough by default) so the program’s path is always obvious to the reader.

  1. Forgetting that switch cases break automatically. Unlike C, Go does not fall through by default. This is a feature, not a bug, but surprises C programmers.

  2. Modifying loop variable captures in closures. The loop variable is reused across iterations. Capturing it in a closure captures the same variable:

    for _, v := range values {
    go func() {
    fmt.Println(v) // all goroutines print the last value
    }()
    }

Fix: pass as an argument go func(val int) { ... }(v).

  1. Using defer in a loop. Each defer in a loop accumulates until the function returns. For large loops, this can exhaust memory. Use an immediately-invoked function or call the cleanup directly.

  2. Range over nil map/slice is safe. for range nil does nothing — it does not panic. This is by design.

  3. Range copies values. Modifying the range variable does not modify the original element. Use index-based access for mutations.

This topic covers the core concepts of control flow, including underlying theory, practical implementation, and key applications.

Key concepts include:

  • core concepts and terminology
  • algorithms and computational thinking
  • practical implementation
  • security and ethical considerations
  • applications in the real world

Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.

Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.