Skip to main content

Pro Tips — Control Structures

Expert-level insights and common pitfalls for Go control flow.


defer Closure Trap — Variable Capture

The trap: deferring a function call inside a loop using the loop variable directly.

package main

import "fmt"

func trapVersion() {
// WRONG: all deferred calls capture the same variable i
for i := 0; i < 3; i++ {
defer func() {
fmt.Println("trap:", i) // always prints 3
}()
}
}

func fixedVersion() {
// CORRECT option 1: pass i as an argument (evaluated immediately)
for i := 0; i < 3; i++ {
defer func(n int) {
fmt.Println("fixed (arg):", n)
}(i)
}
}

func fixedVersion2() {
// CORRECT option 2: shadow the variable inside the loop
for i := 0; i < 3; i++ {
i := i // new variable scoped to this iteration
defer func() {
fmt.Println("fixed (shadow):", i)
}()
}
}

func main() {
fmt.Println("--- trap ---")
trapVersion()
fmt.Println("--- fix (arg) ---")
fixedVersion()
fmt.Println("--- fix (shadow) ---")
fixedVersion2()
}

Note: Go 1.22+ changed the loop variable semantics so that i is a fresh variable per iteration in for range loops. However, the classic for i := 0; i < n; i++ form still shares one i. Know which loop form you are in.


panic vs error — Decision Criteria

The single most important judgment call in Go error handling is when to panic and when to return an error.

Return an error when:

  • The caller can reasonably detect the condition and handle it.
  • The error is part of normal operation (file not found, parse failure, network timeout).
  • The function is part of a public API — callers expect to handle errors.

Panic when:

  • A programming invariant is violated (index out of range, nil dereference of a required dependency).
  • Initialization fails and the program cannot operate in a valid state.
  • The error can only occur due to a bug in the calling code (e.g., regexp.MustCompile panics on invalid patterns because a valid program should never pass one).
package main

import (
"errors"
"fmt"
"regexp"
)

// Good: return error — caller decides how to handle
func parseAge(s string) (int, error) {
var age int
if _, err := fmt.Sscan(s, &age); err != nil {
return 0, fmt.Errorf("parseAge: invalid input %q: %w", s, err)
}
if age < 0 || age > 150 {
return 0, errors.New("parseAge: age out of plausible range")
}
return age, nil
}

// Good: panic — a bad pattern is a compile-time bug
var emailRegex = regexp.MustCompile(`^[^@]+@[^@]+\.[^@]+$`)

func isValidEmail(email string) bool {
return emailRegex.MatchString(email)
}

func main() {
if age, err := parseAge("not-a-number"); err != nil {
fmt.Println("Handled error:", err)
} else {
fmt.Println("Age:", age)
}

fmt.Println("Valid email:", isValidEmail("user@example.com"))
fmt.Println("Valid email:", isValidEmail("notanemail"))
}

Rule of thumb: if a user or external system provides the input, return an error. If only a developer can cause the failure, a panic is acceptable.


switch fallthrough Pitfalls

fallthrough in Go is unconditional — it executes the next case's body regardless of whether its condition would match. This surprises developers coming from other languages.

package main

import "fmt"

func main() {
n := 2
switch n {
case 1:
fmt.Println("one")
case 2:
fmt.Println("two")
fallthrough // blindly falls into case 3's body
case 3:
fmt.Println("three — runs even though n != 3")
case 4:
fmt.Println("four")
}

// The idiomatic alternative: list multiple values
switch n {
case 2, 3: // both handled identically, no fallthrough needed
fmt.Println("two or three (idiomatic)")
}

// fallthrough is invalid as the last statement in a switch
// (compiler error: cannot fallthrough final case in switch)
}

Practical legitimate use case for fallthrough: version migration where each version inherits the operations of the versions below it.

package main

import "fmt"

func migrateFrom(version int) {
fmt.Printf("Migrating from version %d:\n", version)
switch version {
case 1:
fmt.Println(" Apply migration 1→2")
fallthrough
case 2:
fmt.Println(" Apply migration 2→3")
fallthrough
case 3:
fmt.Println(" Apply migration 3→4 (latest)")
default:
fmt.Println(" Already at latest version")
}
}

func main() {
migrateFrom(1) // applies all three migrations
migrateFrom(2) // applies last two
migrateFrom(3) // applies only the last
migrateFrom(4) // already latest
}

range Copy Issue

range over a slice gives you a copy of each element. Mutations to the copy do not affect the original slice.

package main

import "fmt"

type Counter struct {
Name string
Value int
}

func main() {
counters := []Counter{
{"requests", 0},
{"errors", 0},
{"latency", 0},
}

// WRONG: modifying v does not change counters
for _, v := range counters {
v.Value++ // v is a copy
}
fmt.Println("After wrong increment:", counters)
// Output: [{requests 0} {errors 0} {latency 0}]

// CORRECT option 1: use index
for i := range counters {
counters[i].Value++
}
fmt.Println("After index increment:", counters)

// CORRECT option 2: use slice of pointers
pCounters := []*Counter{
{"requests", 0},
{"errors", 0},
}
for _, c := range pCounters {
c.Value++ // c is a copy of the pointer — the struct it points to is shared
}
fmt.Println("After pointer increment:", *pCounters[0], *pCounters[1])
}

Performance: defer Cost

defer is not free. Each defer statement involves allocating a defer record and a function pointer on the goroutine's defer chain. In Go 1.14+, the compiler can open-code simple defer statements (eliminating the allocation entirely), but this optimization applies only when there is a statically known, bounded number of defers.

package main

import (
"fmt"
"os"
"time"
)

const iterations = 10_000_000

func withDefer() {
f, _ := os.Open(os.DevNull)
defer f.Close()
// do work
_ = f
}

func withoutDefer() {
f, _ := os.Open(os.DevNull)
// do work
_ = f
f.Close()
}

func benchmark(name string, fn func()) {
start := time.Now()
for i := 0; i < iterations; i++ {
fn()
}
fmt.Printf("%-16s %v\n", name, time.Since(start))
}

func main() {
benchmark("with defer", withDefer)
benchmark("without defer", withoutDefer)
fmt.Println("\nNote: for hot paths (millions of calls/sec), profile before optimizing.")
fmt.Println("In most code, defer's clarity benefit outweighs its cost.")
}

The general rule: use defer everywhere it improves clarity. Only remove it in profiler-confirmed hot paths.


range String — UTF-8 Handling

Go source code is UTF-8. String literals are byte slices. len(s) returns the number of bytes, not characters. range over a string decodes UTF-8 runes correctly, making it the only safe way to iterate over characters in a Unicode string.

package main

import (
"fmt"
"unicode/utf8"
)

func main() {
s := "Go언어2024"

fmt.Println("len (bytes):", len(s)) // 10
fmt.Println("RuneCount :", utf8.RuneCountInString(s)) // 7

fmt.Println("\nByte indexing (unsafe for non-ASCII):")
for i := 0; i < len(s); i++ {
fmt.Printf(" s[%d] = %#x\n", i, s[i])
}

fmt.Println("\nRange (correct rune iteration):")
for bytePos, r := range s {
fmt.Printf(" byte[%02d] U+%04X %c (%d bytes)\n",
bytePos, r, r, utf8.RuneLen(r))
}

// Slicing at a non-rune boundary corrupts the string
broken := s[2:4] // cuts into the middle of '언'
fmt.Printf("\nBroken slice s[2:4]: %q\n", broken)

// Safe slicing: find the byte index of the nth rune
nthRuneIndex := func(s string, n int) int {
i := 0
for n > 0 {
_, size := utf8.DecodeRuneInString(s[i:])
i += size
n--
}
return i
}
idx := nthRuneIndex(s, 2) // byte index of the 3rd rune
fmt.Printf("s[:nthRune(2)] = %q\n", s[:idx])
}

Summary: Control Flow Best Practices

ScenarioRecommendation
Multiple return pathsEarly return (guard clauses), not deep nesting
Scoped variable in conditionif init; cond initialization statement
Multi-value matchcase a, b, c: — never chain fallthrough
Type-based dispatchType switch with default that panics in tests
Loop with cleanupdefer outside the loop, or extract to a function
Mutating slice elementsUse index (slice[i]) or slice of pointers
String character iterationAlways range, never byte indexing
Unexpected statepanic; recoverable/expected errors → return error
Hot path with many defersProfile first; consider manual cleanup only if needed