Chapter 12: Patterns & Gotchas
Learn to avoid common Go pitfalls and write idiomatic code that other Go developers will appreciate. This chapter collects battle-tested patterns and surprising behaviors that every Go developer encounters eventually.
Go’s simplicity is deceptive. The language has few features, but their interactions create subtle behaviors that catch newcomers (and sometimes experienced developers). These “gotchas” aren’t bugs - they’re consequences of design decisions. Understanding why they happen helps you write correct code and debug issues faster.
Patterns, meanwhile, are proven solutions to recurring problems. Go’s constraints (no inheritance, no generics until 1.18, no operator overloading) force creative solutions. The functional options pattern provides extensible configuration without breaking APIs. The builder pattern enables complex object construction. These patterns emerge from the community’s collective experience building production Go systems.
This chapter documents the most common gotchas and useful patterns. Each gotcha explains what goes wrong and how to avoid it. Each pattern shows when to use it and why it works well in Go. By the end, you’ll recognize these patterns in open-source code and avoid surprises in your own projects.
Gotcha #1: Loop Variable Capture (Fixed in Go 1.22)
Section titled “Gotcha #1: Loop Variable Capture (Fixed in Go 1.22)”The Current Rule
Section titled “The Current Rule”On Go 1.22 and later - which is every supported release - loop variables are per-iteration. A for loop whose header declares variables with := creates fresh variables on every iteration, so a closure or goroutine that captures one sees that iteration’s value. Capturing directly is correct, and you do not need the old func(v int){...}(v) parameter trick.
Two details that are widely misstated:
- It is not limited to
rangeloops. The change applies to everyforloop that declares its variables in the header, including the three-clausefor i := 0; i < n; i++form. - It is gated on the module’s
godirective, not on your toolchain. A module whosego.modstill saysgo 1.21keeps the old per-loop semantics even when built with Go 1.24. Bump the directive togo 1.22or later to get the new behaviour. (You can also opt in per file with a//go:build go1.22line.) This is why the old bug can still bite you in a legacy repo.
Historical Note: What the Bug Looked Like
Section titled “Historical Note: What the Bug Looked Like”You will still meet this in old code, old blog posts, and old tutorials, so it is worth being able to recognise it.
Before Go 1.22 the loop variable had function scope, not iteration scope. Every closure captured the same variable, so by the time the goroutines ran the loop had usually finished and the variable held its final value:
// PRE-1.22 BUG - all five goroutines print 5for _, v := range values { wg.Add(1) go func() { defer wg.Done() fmt.Println(v) }()}The two standard workarounds were to pass the value as an argument, or to shadow it with a fresh declaration:
go func(val int) { ... }(v) // pass as a parameterv := v // or shadow: "the most confusing line in Go"Don’t write either of these in new code. They are noise on a modern module, and v := v now produces a “self-assignment” style complaint from some linters.
What Go 1.22 Did Not Fix
Section titled “What Go 1.22 Did Not Fix”Per-iteration loop variables solved exactly one problem. Closures that capture a variable declared outside the loop are still shared - and still a data race:
name := "" // declared OUTSIDE the loop: ONE variable, sharedfor _, id := range ids { name = fmt.Sprintf("User%d", id) go func() { use(name) // RACE: every goroutine reads the same variable }()}The fix is the same as it always was: declare the variable inside the loop body, or better, compute it inside the goroutine. The exercise at the end of this chapter contains exactly this bug.
Gotcha #2: Nil Slice vs Empty Slice
Section titled “Gotcha #2: Nil Slice vs Empty Slice”Gotcha #3: Nil Map Write
Section titled “Gotcha #3: Nil Map Write”Gotcha #4: Interface Nil Check
Section titled “Gotcha #4: Interface Nil Check”Understanding Interface Nil
Section titled “Understanding Interface Nil”This gotcha is particularly subtle because it violates intuition. An interface variable can be “not nil” while containing a nil pointer. This happens because interfaces store two things: a type and a value. An interface is nil only when both are nil.
When you return a typed nil (like return err where err is *MyError with nil value), you’re returning an interface with type *MyError and value nil. The interface itself is not nil - it has a type. This causes err == nil checks to unexpectedly return false.
The practical impact: functions that return error interfaces must return explicit nil, not typed nils. If you have a variable of concrete error type that might be nil, check it before returning. Return nil for the success case, not the variable. This pattern appears in error handling and optional return values.
Gotcha #5: Defer in Loops
Section titled “Gotcha #5: Defer in Loops”Pattern: Functional Options
Section titled “Pattern: Functional Options”When to Use This Pattern
Section titled “When to Use This Pattern”Functional options solve the problem of configurable constructors. Without options, you either create many constructor variants (NewClient, NewClientWithTimeout, NewClientWithTimeoutAndRetries) or accept massive config structs. Both approaches are brittle - adding configuration means breaking existing code.
The functional options pattern provides extensibility without breaking changes. Each option is a function that modifies the object. Users pass only the options they need, and you can add new options without affecting existing callers. The constructor sets sensible defaults, options override them.
This pattern appears throughout the Go ecosystem: gRPC dial options, HTTP client configuration, database connection options. It’s particularly useful for libraries where you can’t predict what users will need to configure. The trade-off: slight complexity in implementation for significant flexibility in usage.
Pattern: Builder
Section titled “Pattern: Builder”When to Use Builders
Section titled “When to Use Builders”Builders construct complex objects step by step with a fluent API. Each method modifies the builder and returns it, enabling method chaining. This pattern shines when objects have many optional fields or complex validation requirements.
The builder pattern is common for DSLs (domain-specific languages) in Go: SQL query builders, HTTP request builders, test fixture builders. It provides readable, self-documenting construction code. Compare NewQuery(table, columns, where, limit) with NewQueryBuilder(table).Select(cols...).Where(condition).Limit(n).Build() - the latter is more readable and flexible.
Builders work well when construction is complex but usage is simple. The builder encapsulates construction logic, letting users focus on what they’re building rather than how. The final Build() method validates and returns the immutable result, ensuring invalid objects never escape the builder.
Pattern: Result Type
Section titled “Pattern: Result Type”Best Practices Checklist
Section titled “Best Practices Checklist”Go requires every import to appear before any other declaration in the file - a file with import "strings" at the bottom does not compile (“syntax error: imports must appear before other declarations”). Group all imports into a single block at the top:
Putting It All Together
Section titled “Putting It All Together”From Gotchas to Patterns
Section titled “From Gotchas to Patterns”The gotchas and patterns in this chapter represent accumulated wisdom from the Go community. Gotchas arise from Go’s design choices - variable scoping, interface mechanics, nil semantics. They’re not mistakes; they’re trade-offs that usually work well but occasionally surprise.
Patterns emerge as solutions to constraints. Functional options exist because Go lacks default parameters and method overloading. Builders compensate for the absence of complex constructors. The Result type fills a gap for error handling in functional pipelines. Each pattern works with Go’s strengths rather than fighting its limitations.
The exercise that follows tests your ability to spot these patterns and gotchas in realistic code. In production code, bugs from these issues appear subtly - a race condition that only manifests under load, a memory leak from forgetting to close resources, an API that’s painful to extend. Recognizing these patterns early saves debugging time and prevents design mistakes.
Key Takeaways
Section titled “Key Takeaways”- Loop variables - per-iteration since Go 1.22 (gated on the module’s
godirective); capture them directly, and watch for variables declared outside the loop, which are still shared - Nil vs empty - know the difference for slices and maps
- Interface nil - return explicit nil, not typed nil
- Defer in loops - use helper functions
- Functional options - extensible configuration
- Builder pattern - fluent APIs for complex objects
- Fail fast - validate early, return errors immediately
Exercise
Section titled “Exercise”Code Review Challenge
Find and fix the bugs and anti-patterns in this code. There are 5 issues. Heads up: the starter code DEADLOCKS on purpose - `fatal error: all goroutines are asleep - deadlock!` is bug 3 announcing itself, not a broken exercise. Fix the bugs and the program terminates.
Practice
Section titled “Practice”Put the gotchas to work in the exercise battery:
- Goroutine Counter - shared state,
WaitGroup, and the races this chapter warns about - Worker Pool - channel closing done correctly
- Custom Errors - the typed-nil trap from Gotcha #4, in anger
- Clean Architecture Layers - functional options and constructor patterns
- Or browse the full exercise battery
Next up: Chapter 13: Generics