Go Best Practices, Idioms & Design Patterns

Go best practices, idioms, and design patterns at principal-engineer level.

🌱 Seedling·created: ·category:Golang

A Principal-Engineer-Level Deep Dive


Table of Contents

  1. Philosophy of Go
  2. Project Structure & Package Design
  3. Naming Conventions
  4. Error Handling
  5. Interfaces & Composition
  6. Concurrency
  7. Generics
  8. Context
  9. Design Patterns in Go
  10. Testing
  11. Performance & Memory
  12. Common Pitfalls / Anti-Patterns
  13. Tooling & Linting
  14. Logging & Observability
  15. API & Library Design
  16. Documentation

1. Philosophy of Go

Go was designed around a small set of core beliefs, and internalizing them is more important than memorizing syntax:

  • Simplicity over cleverness. Code is read far more than it’s written. Go deliberately omits features (inheritance, operator overloading, exceptions) to keep the language small.
  • Composition over inheritance. There is no class hierarchy. Behavior is built by embedding structs and satisfying interfaces.
  • Explicit over implicit. Errors are values, returned explicitly. No hidden control flow like exceptions.
  • Concurrency as a first-class citizen. Goroutines and channels are baked into the language, not bolted on as a library.
  • “A little copying is better than a little dependency.” Prefer small, local duplication over pulling in a large dependency for one function.
  • gofmt settles all style debates. There is one canonical formatting; don’t fight it.

Rob Pike’s maxims worth internalizing:

  • Clear is better than clever.
  • Reflection is never clear.
  • Errors are values.
  • Don’t just check errors, handle them gracefully.
  • The bigger the interface, the weaker the abstraction.

2. Project Structure & Package Design

2.1 Standard Layout (community convention, not official)

myproject/
├── cmd/
│   └── myapp/
│       └── main.go          # thin entrypoint, wires dependencies
├── internal/
│   ├── domain/               # core business logic, no external deps
│   ├── service/              # application/use-case layer
│   ├── transport/
│   │   ├── http/
│   │   └── grpc/
│   ├── repository/           # persistence adapters
│   └── config/
├── pkg/                       # code safe for external import (use sparingly)
├── api/                        # protobuf/openapi specs
├── migrations/
├── scripts/
├── go.mod
├── go.sum
└── Makefile
  • internal/ is enforced by the Go compiler: nothing outside the module (or outside the parent of internal) can import it. Use it aggressively — default to internal, promote to pkg/ only when you truly want external consumers.
  • cmd/ should contain almost no logic — just flag parsing, config loading, dependency wiring, and calling into internal.
  • Avoid a generic pkg/utils or pkg/common grab-bag. It becomes a dumping ground and creates artificial coupling. Name packages after what they provide, not what they contain.

2.2 Package Naming

// BAD
package utils
package common
package helpers

// GOOD — package name describes what it provides
package validator
package ratelimit
package sqlrepo
  • Package names are lowercase, single-word, no underscores, no mixedCaps.
  • Avoid stutter: validator.Validator is bad; prefer validator.New() returning a validator.Validator is fine but the type name shouldn’t repeat the package unnecessarily — e.g., http.Client, not http.HTTPClient.
  • The package name is part of the call site: log.Info() reads better than logging.LogInfo().

2.3 Dependency Direction

Follow a rough hexagonal / clean-architecture split even without a full framework:

domain  <-- service <-- transport (HTTP/gRPC handlers)
   ^
   |
repository (implements domain interfaces)
  • The domain package should define interfaces (type UserRepository interface {...}) but never import concrete infrastructure (database/sql, net/http).
  • Concrete implementations (postgres.UserRepository) live in repository/ and satisfy the domain interface — this is the Dependency Inversion Principle applied idiomatically in Go: interfaces are declared by the consumer, not the producer.

2.4 Avoid Import Cycles

Go forbids cyclic imports at compile time. If you find yourself needing package A to import B and B to import A, extract the shared contract into a third package (often the domain/interfaces package) that both depend on.


3. Naming Conventions

ElementConventionExample
Packageshort, lowercase, no underscorenet/http, encoding/json
Exported identifiersMixedCapstype Client struct{}
Unexported identifiersmixedCapsfunc parseHeader()
ConstantsMixedCaps (no SCREAMING_SNAKE)const MaxRetries = 3
Interfaces (single method)Method name + -erio.Reader, io.Writer, fmt.Stringer
Acronymskeep consistent caseURL, ID, HTTP not Url, Id, Http
Gettersno Get prefixuser.Name() not user.GetName()
Errors (sentinel)Err prefixvar ErrNotFound = errors.New(...)
Error typesError suffixtype ValidationError struct{}
  • Receiver names should be short (1-2 letters), consistent across all methods of a type, and never self or this:
func (c *Client) Do(req *Request) (*Response, error) { ... }
func (c *Client) Close() error { ... }
  • Variable scope should dictate name length. Tight loops: i, j, k, v. Package-level: descriptive, full words.
  • Avoid repeating the package name in the identifier: bytes.Buffer not bytes.BytesBuffer; strings.NewReplacer not strings.NewStringReplacer.

4. Error Handling

4.1 Errors Are Values

func Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("divide: division by zero")
    }
    return a / b, nil
}

Always check errors immediately. Never discard with _ unless you have documented why it’s safe.

4.2 Wrapping with Context (%w)

func LoadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("load config %q: %w", path, err)
    }
    var cfg Config
    if err := yaml.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("parse config %q: %w", path, err)
    }
    return &cfg, nil
}
  • %w preserves the error chain so callers can use errors.Is / errors.As.
  • Each wrap should add new contextual information, not repeat the same phrase up the stack.
  • Don’t capitalize error strings, don’t end with punctuation (fmt.Errorf("Failed to open.") is wrong style — should be "failed to open: %w").

4.3 Sentinel Errors vs Error Types vs Opaque Errors

// Sentinel — for simple identity checks
var ErrNotFound = errors.New("resource not found")

if errors.Is(err, ErrNotFound) { ... }

// Custom error type — when you need structured data
type ValidationError struct {
    Field string
    Msg   string
}
func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Msg)
}

var ve *ValidationError
if errors.As(err, &ve) {
    log.Printf("bad field: %s", ve.Field)
}

// Opaque — caller only needs to know success/failure, not the type
func DoSomething() error { ... } // caller just checks err != nil

Prefer the least amount of exposed error surface that still meets the caller’s needs — “opaque errors” is a valid and often preferable default.

4.4 Multi-Errors (Go 1.20+)

err := errors.Join(err1, err2, err3)
if errors.Is(err, ErrTimeout) { ... }

4.5 panic/recover

  • panic is reserved for programmer errors (nil dereference of an invariant that should never be nil, index out of range from a logic bug) — not for expected failure paths.
  • Never let a panic cross an API/library boundary silently. If a library must use panic internally (e.g. recursive descent parsers), it should recover() at the exported entry point and convert to an error.
  • In servers, use a top-level recover() middleware so a single goroutine panic doesn’t crash the whole process — but treat every recovered panic as a bug to fix, not a routine control-flow mechanism.
func Parse(input string) (result Node, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("parse: %v", r)
        }
    }()
    return parseInternal(input), nil
}

4.6 Don’t Log AND Return an Error

A very common anti-pattern:

// BAD — duplicate/triple-logged errors as they propagate up
if err != nil {
    log.Println(err)
    return err
}

Handle an error once — either log-and-swallow at the top of the call stack, or wrap-and-return. Doing both at every layer produces log spam with the same root cause repeated N times.


5. Interfaces & Composition

5.1 Accept Interfaces, Return Structs

// GOOD
func NewUserService(repo UserRepository) *UserService { ... }

// Consumer defines the minimal interface it needs
type UserRepository interface {
    FindByID(ctx context.Context, id string) (*User, error)
}
  • Interfaces should be small, ideally 1-3 methods. io.Reader, io.Writer, sort.Interface are canonical examples.
  • Interfaces belong to the consumer’s package, not the producer’s. Don’t pre-declare interfaces in the package that implements them “just in case” — that’s a C#/Java habit that doesn’t fit Go’s structural typing.
  • Return concrete types from constructors so callers get full access to exported methods/fields; let them narrow to an interface if/when they need to, e.g., in a test with a mock.

5.2 Structural Typing / Implicit Satisfaction

type Stringer interface {
    String() string
}

type Point struct{ X, Y int }
func (p Point) String() string { return fmt.Sprintf("(%d,%d)", p.X, p.Y) }

// Point automatically satisfies Stringer — no "implements" keyword needed
var s Stringer = Point{1, 2}

Compile-time assertion when you want to guarantee a type satisfies an interface (common in implementations):

var _ io.Writer = (*MyWriter)(nil)

5.3 Embedding for Composition

type Base struct {
    ID string
}
func (b Base) Describe() string { return "id=" + b.ID }

type User struct {
    Base   // embedded — promotes Describe() to User
    Name string
}

u := User{Base: Base{ID: "1"}, Name: "Ada"}
u.Describe() // works — promoted method
  • Embedding is not inheritance: there’s no polymorphism, no super, and the embedded type’s methods don’t know about the outer type (no virtual dispatch).
  • Useful for decorating interfaces — e.g., wrapping an io.Writer while embedding it to inherit the rest of the interface if it’s larger:
type countingWriter struct {
    io.Writer
    n int64
}
func (w *countingWriter) Write(p []byte) (int, error) {
    n, err := w.Writer.Write(p)
    w.n += int64(n)
    return n, err
}

5.4 The Empty Interface & any

Use any (alias for interface{} since Go 1.18) sparingly — it defeats static typing. Prefer generics (see §7) when you need type-agnostic behavior with type safety.


6. Concurrency

6.1 Goroutines

go func() {
    // never let this goroutine leak — always have a clear exit path
}()
  • Every goroutine you start must have a well-defined way to stop. A goroutine with no termination path is a leak, just like an unclosed file handle.
  • Never start a goroutine without knowing who “owns” its lifecycle and how errors propagate out of it.

6.2 Channels

ch := make(chan int)       // unbuffered — synchronous handoff
ch := make(chan int, 10)   // buffered — up to 10 in flight without blocking sender
  • “Don’t communicate by sharing memory; share memory by communicating.”
  • Unbuffered channels are a rendezvous point — great for signaling completion.
  • The sender should close a channel, never the receiver. Closing a channel you don’t own, or closing twice, panics.
  • nil channels block forever on send/receive — useful for disabling a select case dynamically:
var timeout <-chan time.Time
if useTimeout {
    timeout = time.After(5 * time.Second)
}
select {
case v := <-ch:
    ...
case <-timeout: // if nil, this case never fires
    ...
}

6.3 sync Primitives

var mu sync.Mutex
mu.Lock()
defer mu.Unlock()

var once sync.Once
once.Do(func() { initialize() })

var wg sync.WaitGroup
wg.Add(len(tasks))
for _, t := range tasks {
    t := t // capture loop var (see pitfalls §12)
    go func() {
        defer wg.Done()
        process(t)
    }()
}
wg.Wait()
  • Use channels for coordination and flow of data; use sync.Mutex for protecting shared state (a cache, a counter). Neither is universally “more idiomatic” — pick based on the shape of the problem.
  • sync.RWMutex when reads vastly outnumber writes.
  • sync/atomic for simple counters instead of a full mutex when contention matters:
var counter atomic.Int64
counter.Add(1)

6.4 errgroup — The Idiomatic Fan-Out/Fan-In

import "golang.org/x/sync/errgroup"

g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
    url := url
    g.Go(func() error {
        return fetch(ctx, url)
    })
}
if err := g.Wait(); err != nil {
    return err
}

errgroup cancels the shared context as soon as any goroutine returns an error, and collects the first error — this is the standard replacement for hand-rolled WaitGroup + error channel plumbing.

6.5 Worker Pools

func workerPool(ctx context.Context, jobs <-chan Job, workers int) <-chan Result {
    results := make(chan Result)
    var wg sync.WaitGroup
    wg.Add(workers)
    for i := 0; i < workers; i++ {
        go func() {
            defer wg.Done()
            for job := range jobs {
                select {
                case results <- process(job):
                case <-ctx.Done():
                    return
                }
            }
        }()
    }
    go func() {
        wg.Wait()
        close(results)
    }()
    return results
}

Pattern: bounded concurrency, context-aware cancellation, and a dedicated closer goroutine so the caller can safely range over results.

6.6 Pipeline Pattern

func generate(ctx context.Context, nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, n := range nums {
            select {
            case out <- n:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}

func square(ctx context.Context, in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            select {
            case out <- n * n:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}

// usage: for v := range square(ctx, generate(ctx, 1, 2, 3)) { ... }

Each stage: owns its output channel, closes it when done, respects ctx.Done() for cancellation.

6.7 The Race Detector

Always run tests with go test -race in CI. It’s not optional for concurrent code — data races are undefined behavior in Go’s memory model, and the race detector catches the vast majority of real-world cases.


7. Generics

Introduced in Go 1.18. Use them when you’d otherwise duplicate logic across types or rely on interface{} + reflection/type assertions.

7.1 Basic Syntax

func Map[T, U any](s []T, f func(T) U) []U {
    result := make([]U, len(s))
    for i, v := range s {
        result[i] = f(v)
    }
    return result
}

doubled := Map([]int{1, 2, 3}, func(n int) int { return n * 2 })

7.2 Constraints

type Number interface {
    ~int | ~int64 | ~float64
}

func Sum[T Number](nums []T) T {
    var total T
    for _, n := range nums {
        total += n
    }
    return total
}
  • ~int means “int, or any named type whose underlying type is int” — important so your generic function also works on type Age int.
  • The standard library’s cmp.Ordered (Go 1.21+) covers most ordering constraints — prefer it over hand-rolling.
  • Package slices and maps (Go 1.21+ stdlib) already provide generic Sort, Contains, Keys, Values, etc. — check stdlib before writing your own.

7.3 When NOT to Use Generics

  • Don’t genericize a function that only ever has one concrete use — YAGNI applies.
  • If an interface with 1-2 methods solves the problem, prefer that over a type parameter — generics shine for data structures (containers, algorithms over slices/maps) more than for behavior abstraction, which interfaces already handle well.

8. Context

8.1 The Rules

  1. context.Context should be the first parameter of a function, named ctx.
  2. Never store a Context inside a struct field — pass it explicitly through the call chain (documented exceptions are extremely rare, e.g. some http.Request.Context() internal cases).
  3. Never pass nil — use context.TODO() if genuinely undecided, context.Background() at the true root (main, tests, top-level handler).
  4. Use context.WithValue sparingly — only for request-scoped metadata that transits API boundaries (trace IDs, auth tokens), never for optional parameters or dependency injection.
func FetchUser(ctx context.Context, id string) (*User, error) {
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    ...
}

8.2 Cancellation Propagation

func longRunningTask(ctx context.Context) error {
    for {
        select {
        case <-ctx.Done():
            return ctx.Err() // context.Canceled or context.DeadlineExceeded
        default:
            // do a chunk of work
        }
    }
}

8.3 Custom Context Keys

type ctxKey int
const requestIDKey ctxKey = 0

func WithRequestID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, requestIDKey, id)
}

func RequestIDFromContext(ctx context.Context) (string, bool) {
    id, ok := ctx.Value(requestIDKey).(string)
    return id, ok
}

Use an unexported custom type for the key (never a raw string) to avoid collisions across packages.


9. Design Patterns in Go

Go doesn’t have classes, so classical GoF patterns are re-expressed via interfaces, composition, and first-class functions. Below are the ones that come up constantly in real Go codebases.

9.1 Functional Options

The idiomatic replacement for constructor overloading / builder-with-setters:

type Server struct {
    addr    string
    timeout time.Duration
    tls     bool
}

type Option func(*Server)

func WithTimeout(d time.Duration) Option {
    return func(s *Server) { s.timeout = d }
}
func WithTLS() Option {
    return func(s *Server) { s.tls = true }
}

func NewServer(addr string, opts ...Option) *Server {
    s := &Server{addr: addr, timeout: 30 * time.Second} // sane defaults
    for _, opt := range opts {
        opt(s)
    }
    return s
}

// usage
srv := NewServer(":8080", WithTimeout(5*time.Second), WithTLS())

Benefits: backward-compatible API evolution (adding an option doesn’t break callers), self-documenting call sites, sensible defaults.

9.2 Builder Pattern (when options aren’t enough)

type QueryBuilder struct {
    table string
    wheres []string
}

func NewQuery(table string) *QueryBuilder {
    return &QueryBuilder{table: table}
}
func (q *QueryBuilder) Where(cond string) *QueryBuilder {
    q.wheres = append(q.wheres, cond)
    return q
}
func (q *QueryBuilder) Build() string {
    query := "SELECT * FROM " + q.table
    if len(q.wheres) > 0 {
        query += " WHERE " + strings.Join(q.wheres, " AND ")
    }
    return query
}

sql := NewQuery("users").Where("age > 18").Where("active = true").Build()

9.3 Strategy Pattern

type CompressionStrategy interface {
    Compress([]byte) ([]byte, error)
}

type GzipStrategy struct{}
func (GzipStrategy) Compress(b []byte) ([]byte, error) { /* ... */ return b, nil }

type ZstdStrategy struct{}
func (ZstdStrategy) Compress(b []byte) ([]byte, error) { /* ... */ return b, nil }

type Archiver struct {
    strategy CompressionStrategy
}
func (a *Archiver) Archive(data []byte) ([]byte, error) {
    return a.strategy.Compress(data)
}

In Go, a strategy is often just a function value rather than an interface with one method:

type CompressFunc func([]byte) ([]byte, error)

type Archiver struct {
    compress CompressFunc
}

9.4 Decorator Pattern

type Handler func(http.ResponseWriter, *http.Request)

func WithLogging(next Handler) Handler {
    return func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next(w, r)
        log.Printf("%s %s took %v", r.Method, r.URL.Path, time.Since(start))
    }
}

func WithAuth(next Handler) Handler {
    return func(w http.ResponseWriter, r *http.Request) {
        if !isAuthorized(r) {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        next(w, r)
    }
}

// composition
handler := WithLogging(WithAuth(myHandler))

This is Go’s idiomatic middleware pattern, ubiquitous in HTTP servers (chi, echo, gin all use variants of it).

9.5 Observer Pattern (Pub/Sub via Channels)

type EventBus struct {
    mu   sync.RWMutex
    subs map[string][]chan Event
}

func (b *EventBus) Subscribe(topic string) <-chan Event {
    b.mu.Lock()
    defer b.mu.Unlock()
    ch := make(chan Event, 1)
    b.subs[topic] = append(b.subs[topic], ch)
    return ch
}

func (b *EventBus) Publish(topic string, evt Event) {
    b.mu.RLock()
    defer b.mu.RUnlock()
    for _, ch := range b.subs[topic] {
        select {
        case ch <- evt:
        default: // drop if subscriber is slow — non-blocking publish
        }
    }
}

9.6 Singleton (used sparingly — prefer explicit dependency injection)

var (
    instance *Config
    once     sync.Once
)

func GetConfig() *Config {
    once.Do(func() {
        instance = loadConfig()
    })
    return instance
}

Singletons make testing harder (hidden global state) — favor passing dependencies explicitly through constructors wherever feasible. Reserve this pattern for things that are genuinely process-wide, like a metrics registry.

9.7 Factory Pattern

type StorageType string

const (
    StorageS3    StorageType = "s3"
    StorageLocal StorageType = "local"
)

func NewStorage(t StorageType, cfg Config) (Storage, error) {
    switch t {
    case StorageS3:
        return NewS3Storage(cfg)
    case StorageLocal:
        return NewLocalStorage(cfg)
    default:
        return nil, fmt.Errorf("unknown storage type: %s", t)
    }
}

9.8 Adapter Pattern

// third-party lib has its own logger interface, incompatible with ours
type thirdPartyLogger interface {
    LogMessage(level, msg string)
}

type LoggerAdapter struct {
    logger *slog.Logger
}
func (a *LoggerAdapter) LogMessage(level, msg string) {
    a.logger.Log(context.Background(), slog.Level(0), msg, "level", level)
}

9.9 State Pattern (via interfaces + type switch, or explicit state machine)

type OrderState interface {
    Next(o *Order) OrderState
    Name() string
}

type Pending struct{}
func (Pending) Name() string { return "pending" }
func (Pending) Next(o *Order) OrderState { return Shipped{} }

type Shipped struct{}
func (Shipped) Name() string { return "shipped" }
func (Shipped) Next(o *Order) OrderState { return Delivered{} }

9.10 Pipeline / Chain of Responsibility

Already shown in §6.6 — Go’s channel-based pipelines are the idiomatic chain-of-responsibility for streaming data. For synchronous middleware-style chains, see the Decorator pattern (§9.4).

9.11 Null Object Pattern

type NoopMetrics struct{}
func (NoopMetrics) Inc(name string)          {}
func (NoopMetrics) Observe(name string, v float64) {}

// used as default so callers never need a nil-check
var DefaultMetrics Metrics = NoopMetrics{}

10. Testing

10.1 Table-Driven Tests

The single most important Go testing idiom:

func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        want     int
    }{
        {"positive", 2, 3, 5},
        {"negative", -1, -1, -2},
        {"zero", 0, 0, 0},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := Add(tt.a, tt.b)
            if got != tt.want {
                t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
            }
        })
    }
}

10.2 Subtests & Parallelism

func TestSlow(t *testing.T) {
    t.Parallel()
    // ...
}

Use t.Parallel() for independent tests to speed up CI, but be careful with shared mutable fixtures (loop variable capture applies to tt in table tests too — Go 1.22+ fixed the classic loop-var bug, but for pre-1.22 code, always shadow: tt := tt).

10.3 Test Doubles via Interfaces

type EmailSender interface {
    Send(to, subject, body string) error
}

type mockSender struct {
    sent []string
}
func (m *mockSender) Send(to, subject, body string) error {
    m.sent = append(m.sent, to)
    return nil
}

func TestNotifyUser(t *testing.T) {
    mock := &mockSender{}
    svc := NewNotificationService(mock)
    svc.Notify("a@example.com")
    if len(mock.sent) != 1 {
        t.Fatalf("expected 1 email sent, got %d", len(mock.sent))
    }
}

Small consumer-defined interfaces (§5.1) are precisely what make this trivial — no mocking framework required for most cases, though gomock/mockery/testify/mock help for larger surfaces.

10.4 golden Files

func TestRender(t *testing.T) {
    got := Render(input)
    golden := filepath.Join("testdata", "render.golden")
    if *update {
        os.WriteFile(golden, got, 0644)
    }
    want, _ := os.ReadFile(golden)
    if !bytes.Equal(got, want) {
        t.Errorf("mismatch, run with -update to regenerate")
    }
}

10.5 Fuzz Testing (Go 1.18+)

func FuzzParse(f *testing.F) {
    f.Add("valid input")
    f.Fuzz(func(t *testing.T, s string) {
        _, err := Parse(s)
        if err != nil {
            return // ok, invalid input
        }
        // if it parses, re-marshaling should round-trip
    })
}

10.6 Benchmark Tests

func BenchmarkConcat(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = strings.Join([]string{"a", "b", "c"}, "")
    }
}

Run with go test -bench=. -benchmem to see allocations per op — critical for hot-path optimization decisions.

10.7 Test Organization

  • Keep tests in the same package (package foo) for white-box testing of internals, or package foo_test for pure black-box testing of the public API — many mature projects use foo_test deliberately to catch accidental reliance on unexported details.
  • testdata/ directory is ignored by the go tool — put fixtures there.
  • Use t.Helper() in test helper functions so failure line numbers point to the caller.
  • Use t.Cleanup() instead of manual defer-based teardown for composability.

11. Performance & Memory

11.1 Preallocate Slices When Size Is Known

// BAD — repeated reallocation/copy as the slice grows
var result []int
for _, v := range input {
    result = append(result, transform(v))
}

// GOOD
result := make([]int, 0, len(input))
for _, v := range input {
    result = append(result, transform(v))
}

11.2 Avoid Unnecessary Allocations

  • Passing large structs by value copies them — pass pointers for structs beyond a few words, but pass small structs (like time.Time-sized) by value where semantics call for immutability.
  • String concatenation in a loop: use strings.Builder, not +=.
var b strings.Builder
b.Grow(estimatedSize)
for _, s := range parts {
    b.WriteString(s)
}
result := b.String()

11.3 Understand Escape Analysis

// n escapes to heap because a pointer to it is returned
func newInt(v int) *int {
    n := v
    return &n
}

Run go build -gcflags="-m" to see escape analysis decisions. Not everything needs manual tuning, but for hot paths, understanding what forces heap allocation (returning pointers, storing in interfaces, closures capturing by reference) matters.

11.4 sync.Pool for Reusable Buffers

var bufPool = sync.Pool{
    New: func() any { return new(bytes.Buffer) },
}

func process() {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()
    defer bufPool.Put(buf)
    // use buf
}

Only worth it for objects that are expensive to allocate and used in hot, high-throughput paths (e.g., HTTP handlers under heavy load). Profile first.

11.5 Profiling

import _ "net/http/pprof"
// then: go tool pprof http://localhost:6060/debug/pprof/profile
  • pprof for CPU/memory/goroutine/block/mutex profiling.
  • go test -cpuprofile=cpu.out -memprofile=mem.out
  • Always measure before optimizing. Go’s compiler and runtime are good; premature micro-optimization often hurts readability for no measurable gain.

11.6 Struct Field Ordering (Alignment Padding)

// BAD — 24 bytes due to padding
type Bad struct {
    A bool    // 1 byte + 7 padding
    B int64   // 8 bytes
    C bool    // 1 byte + 7 padding
}

// GOOD — 16 bytes, fields ordered largest to smallest
type Good struct {
    B int64
    A bool
    C bool
}

Use go vet or fieldalignment (from golang.org/x/tools/go/analysis/passes/fieldalignment) to catch this automatically in large structs.


12. Common Pitfalls / Anti-Patterns

12.1 Loop Variable Capture (fixed in Go 1.22, still relevant for older code / reading legacy code)

// BAD (Go < 1.22) — all goroutines may print the same final value
for _, v := range items {
    go func() { fmt.Println(v) }()
}

// FIX (pre-1.22)
for _, v := range items {
    v := v
    go func() { fmt.Println(v) }()
}

// or pass as parameter
for _, v := range items {
    go func(v int) { fmt.Println(v) }(v)
}

Go 1.22+ changed loop semantics so each iteration gets its own variable — but you must still know this when reading pre-1.22 code or setting go.mod compatibility.

12.2 nil Interface vs nil Pointer

type MyError struct{}
func (e *MyError) Error() string { return "boom" }

func doWork() error {
    var e *MyError = nil
    if false {
        e = &MyError{}
    }
    return e // returns a NON-nil interface wrapping a nil pointer!
}

err := doWork()
fmt.Println(err == nil) // false! classic gotcha

Fix: return nil explicitly, don’t return a typed nil pointer through an interface-typed return value unless intentional.

12.3 Shadowing Errors with :=

func do() error {
    x, err := step1()
    if err != nil {
        return err
    }
    if x > 0 {
        y, err := step2() // shadows outer err inside this block!
        if err != nil {
            return err
        }
        _ = y
    }
    return err // BUG: this outer err may be stale/nil, masking real intent
}

12.4 Ignoring defer in Loops

// BAD — file handles accumulate, closed only when function returns
for _, path := range paths {
    f, _ := os.Open(path)
    defer f.Close() // all deferred to end of enclosing function, not loop iteration
    process(f)
}

// GOOD — wrap in a function so defer fires each iteration
for _, path := range paths {
    func() {
        f, _ := os.Open(path)
        defer f.Close()
        process(f)
    }()
}

12.5 Misusing Goroutines Without Synchronization

// BAD — main may exit before goroutine runs; also a data race on counter
counter := 0
go func() { counter++ }()
fmt.Println(counter)

12.6 Comparing Structs with Uncomparable Fields

type S struct {
    M map[string]int // maps aren't comparable
}
// s1 == s2 // compile error

Use reflect.DeepEqual or a manual Equal method for such types.

12.7 Slice Aliasing / Append Surprises

a := []int{1, 2, 3, 4, 5}
b := a[1:3]         // shares backing array with a
b = append(b, 99)   // may overwrite a[3] if capacity allows!

Use a three-index slice a[1:3:3] to limit capacity and force a new allocation on append, or copy() when you need true isolation.

12.8 Interface Pollution

Don’t define an interface until you have (or clearly anticipate) more than one implementation, or you need it for mocking in tests. A package that exports only one implementation of every interface is over-abstracted — this is a common carryover from Java/C# habits that doesn’t serve Go well.

12.9 Returning Naked/Unwrapped Errors from Deep Call Stacks

Without wrapping, callers three layers up have no idea where an error like "connection refused" originated. Always add context at each layer boundary (see §4.2).

12.10 Overusing init()

init() functions run implicitly at import time in unpredictable relative order (well-defined within a package, less obvious across packages), make testing harder, and hide control flow. Prefer explicit initialization functions called from main.


13. Tooling & Linting

ToolPurpose
gofmt / goimportsCanonical formatting + import grouping (run on save)
go vetCatches suspicious constructs (printf format mismatches, struct tag typos)
staticcheckThe de-facto standard linter; catches bugs go vet misses
golangci-lintMeta-linter aggregating staticcheck, errcheck, govet, revive, etc.
errcheckFlags unchecked error return values
go test -raceData race detector — mandatory in CI for concurrent code
go test -coverCoverage reporting
govulncheckScans dependencies for known CVEs
go mod tidyKeeps go.mod/go.sum accurate

Recommended golangci-lint baseline config includes at least: govet, errcheck, staticcheck, unused, ineffassign, gosimple, bodyclose, noctx, gosec.

# .golangci.yml (minimal example)
linters:
  enable:
    - errcheck
    - gosimple
    - govet
    - ineffassign
    - staticcheck
    - unused
    - bodyclose
    - noctx
    - gosec

14. Logging & Observability

14.1 Structured Logging with log/slog (stdlib, Go 1.21+)

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("request handled",
    "method", r.Method,
    "path", r.URL.Path,
    "duration_ms", elapsed.Milliseconds(),
    "status", status,
)
  • Prefer structured (key-value) logs over fmt.Sprintf-style strings — machine-parseable, filterable in log aggregators.
  • Attach a request-scoped logger (with trace ID, user ID, etc.) via context, not globals.

14.2 Metrics & Tracing

  • expvar (stdlib) for basic process metrics; Prometheus client library (prometheus/client_golang) is the de-facto standard for metrics.
  • OpenTelemetry (go.opentelemetry.io/otel) for distributed tracing — instrument at service boundaries (HTTP handlers, DB calls, outbound RPCs).

15. API & Library Design

  • Keep the exported surface minimal. Anything exported is a promise to callers; unexport everything not needed externally.
  • Semantic versioning matters for modules. A breaking change requires a new major version path (github.com/you/pkg/v2).
  • Avoid exposing third-party types in your public API where possible — it locks consumers to your dependency choices and versions.
  • Provide context-aware and non-context-aware variants sparingly — usually just require context.Context; don’t maintain two parallel APIs.
  • Document exported identifiers with a doc comment starting with the identifier’s name (enforced idiom, checked by golint/revive):
// Client manages a connection to the remote service and is safe
// for concurrent use by multiple goroutines.
type Client struct { ... }

// Do sends req and returns the parsed response. It respects
// ctx cancellation and deadlines.
func (c *Client) Do(ctx context.Context, req *Request) (*Response, error) { ... }

16. Documentation

  • Every exported package should have a package-level doc comment, conventionally in a doc.go file for larger packages:
// Package ratelimit provides token-bucket rate limiters safe for
// concurrent use across multiple goroutines.
package ratelimit
  • Use runnable Example functions — they’re compiled, optionally executed as tests, and shown directly in godoc:
func ExampleClient_Do() {
    c := NewClient()
    resp, _ := c.Do(context.Background(), req)
    fmt.Println(resp.Status)
    // Output: 200 OK
}
  • Keep README focused on: what the project does, install instructions, a minimal usage example, and a link to full godoc — not a duplicate of API reference docs.

Closing Checklist for Code Review

  • Errors wrapped with context, checked immediately, handled once
  • No goroutine without a clear termination/ownership story
  • Interfaces small and consumer-defined
  • context.Context threaded properly, never stored in structs
  • No loop-variable capture bugs (or confirmed Go 1.22+)
  • go vet, staticcheck, -race all clean
  • Exported identifiers documented
  • No premature interface/generic abstraction
  • Table-driven tests for non-trivial logic
  • Struct fields ordered to minimize padding (hot structs only)

This note is part of the Digital Garden — a collection of connected, evolving thoughts.