Skip to content

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.

Loop variable capture has caused countless bugs in Go programs. The symptom: goroutines or closures that mysteriously see the same value instead of different iteration values. This happens because loop variables are reused across iterations - they’re not recreated each time.

Pre-Go 1.22, the loop variable had function scope, not iteration scope. All closures captured the same variable, so by the time goroutines execute, the loop has finished and the variable holds the final value. Go 1.22 changed this for range loops, but understanding the pattern remains valuable for older code and for clarity.

The fix is simple: pass the loop variable as a function parameter, creating a new copy for each iteration. This guarantees each goroutine sees its own value, independent of the loop’s progress.

One of the most common Go mistakes (fixed in Go 1.22+):

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.

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.

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.

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.

  1. Loop variables - pass to goroutines explicitly (pre-1.22)
  2. Nil vs empty - know the difference for slices and maps
  3. Interface nil - return explicit nil, not typed nil
  4. Defer in loops - use helper functions
  5. Functional options - extensible configuration
  6. Builder pattern - fluent APIs for complex objects
  7. Fail fast - validate early, return errors immediately

Code Review Challenge

medium

Find and fix all the bugs and anti-patterns in this code. There are at least 5 issues.


You’ve completed Go Mastery! You now have a solid understanding of:

  • Interface design and type system
  • Error handling patterns
  • Memory management and optimization
  • Concurrent programming with goroutines and channels
  • Clean architecture principles
  • Testing strategies
  • Performance profiling

Keep practicing, reading Go source code, and building projects. The Go community is welcoming - contribute to open source when you’re ready!

Go forth and build great things!


Chapter in progress
0 / 14 chapters completed

Next up: Chapter 13: Generics