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.MustCompilepanics 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
| Scenario | Recommendation |
|---|---|
| Multiple return paths | Early return (guard clauses), not deep nesting |
| Scoped variable in condition | if init; cond initialization statement |
| Multi-value match | case a, b, c: — never chain fallthrough |
| Type-based dispatch | Type switch with default that panics in tests |
| Loop with cleanup | defer outside the loop, or extract to a function |
| Mutating slice elements | Use index (slice[i]) or slice of pointers |
| String character iteration | Always range, never byte indexing |
| Unexpected state | panic; recoverable/expected errors → return error |
| Hot path with many defers | Profile first; consider manual cleanup only if needed |