languages/go/best_practices.md

Go Best Practices

Current idiomatic-vs-outdated verdicts for Go error handling (errors.AsType, errors.Join), concurrency (WaitGroup.Go, testing/synctest, errgroup), and context.Context - backed by named production goroutine-leak research (Uber LeakProf/goleak) and verified Go 1.22-1.26 semantic changes, not restated Go-101 material.

Assumes fluency with errors-as-values, structural interfaces, goroutines, channels, and context.Context. This document covers only where current idiomatic practice has moved, and named real-world failure patterns - not the mechanics themselves.

Error Handling: Current Verdicts

SituationOutdated patternCurrent idiom (verified)Why
Extract a typed error from a chainvar pe *fs.PathError; if errors.As(err, &pe)pe, ok := errors.AsType*fs.PathError (Go 1.26+)Same semantics, ~10x faster / zero-alloc for a single wrap per the accepted proposal's own benchmarks (antonz.org/accepted/errors-astype); reads as an ordinary two-value form, no pre-declared var. Only available on go 1.26+ toolchains - keep errors.As if the module still targets 1.25 or older.
Combine several independent errors (e.g. parallel validation)github.com/hashicorp/go-multierrorerrors.Join (stdlib since 1.20)errors.Join's Unwrap() []error is understood natively by errors.Is/errors.As; multierror needs its own unwrap semantics and an extra dependency. Reach for go-multierror/go.uber.org/multierr only for their extras (Group, safe defer-append) - not as the default.
Sentinel vs custom error type(unchanged) default sentinel, upgrade to a struct type only when the caller needs structured fields-No version-driven change here; still correct guidance.
// go 1.26+
if pe, ok := errors.AsType[*fs.PathError](err); ok {
    log.Printf("path error on %s: %v", pe.Path, pe.Err)
}

Concurrency: What Changed and What Didn't

SituationOutdated patternCurrent idiomVersion
Spawn N goroutines, wait for allwg.Add(1) before go func(){ defer wg.Done(); ... }() - a classic source of Add/Done mismatch panicswg.Go(func() { ... })sync.WaitGroup.Go, Go 1.25 - structurally impossible to mismatch Add/Done since there's no separate Add call. f must not panic; wrap args in a closure since f takes none.
Spawn N goroutines needing first-error propagation + cancellationHand-rolled buffered-channel fan-in (still correct, but boilerplate)golang.org/x/sync/errgroup (g.Go/g.Wait, errgroup.WithContext)Unchanged guidance, still the right default for "wait for N, bail on first error."
Test that a function doesn't leak goroutines or has correct timeout/cancel behaviortime.Sleep(N * time.Millisecond) then assert, or third-party uber-go/goleaktesting/synctest.Test - runs the test in an isolated "bubble" with virtualized time; panics with "main bubble goroutine has exited but blocked goroutines remain" if any goroutine is durably blocked when the test function returnsStable since Go 1.25 (experimental in 1.24 behind GOEXPERIMENT=synctest). Prefer this over goleak for new test code - it needs no manual leak-check call and it deterministically fast-forwards virtual time instead of sleeping wall-clock time.
Sample leaked goroutines in a running production processManual pprof goroutine-count diffing over timeruntime/pprof's goroutineleak profile (experimental, Go 1.26)First stdlib-native leak profile; still young - Uber's goleak/LeakProf (below) remain the field-tested option for now.
func TestNoLeak(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        ch := make(chan int)
        go func() { ch <- compute() }()
        if got := <-ch; got != want {
            t.Errorf("got %d, want %d", got, want)
        }
    })
}

Named production research on goroutine leaks

Uber studied 75 million lines of Go across ~2,500 internal microservices and built two tools now open-sourced: goleak (test-time leak assertions) and LeakProf (lightweight in-production leak detection sampling live processes without the overhead of a full pprof goroutine dump). Their finding: partial deadlocks from message-passing bugs are common enough at scale to justify dedicated tooling beyond go vet/-race, neither of which catches a goroutine blocked forever on a channel with no reader. Fixing 21 leaks in one internal service produced up to a 34% speedup and 9.2x memory reduction - leaks aren't just a slow OOM, they measurably degrade the service before that.

The recurring shape behind real incident writeups (worker-pool / fan-out-fan-in code) is the same one repeatedly: an unbuffered or under-buffered results channel fills because the consumer stopped reading (often because it returned early on the first error), every producer goroutine blocks on the send, and since those producers were also the thing supposed to drain an upstream jobs channel, the whole pipeline gridlocks - not just a leak, a full deadlock. The fix is structural, not a bigger buffer: either size the channel to the known bounded number of sends (as in the classic fan-out pattern), or decouple the consumer into its own goroutine so a slow/early-exiting caller never blocks a producer.

select {
case out <- result:
case <-ctx.Done():
    return ctx.Err() // never block a producer with no escape hatch
}

context.Context: Anti-Patterns Actually Seen in Production Code

Anti-patternWhy it's wrong
func f(ctx *context.Context)Context is already an interface/reference-shaped value; passing *Context adds nil-check surface and pointer indirection for zero benefit. Always pass by value.
Stashing a DB handle, logger, or other long-lived dependency in context.WithValue to shorten a function signatureHides a real dependency from the compiler and from readers; failures surface as a runtime type-assertion panic instead of a compile error, and it makes the function untestable without constructing a fake context. context.Value is for request-scoped data that is genuinely created/destroyed with the request (trace ID, deadline, auth principal) - not for DI.
Storing a context.Context in a struct field so it can be reused across callsContexts are scoped to one call chain, not to an object's lifetime; a stored context silently carries a stale deadline/cancellation into unrelated later calls. A function should never return a context either.
context.TODO() left in stable, shipped codeTODO() signals "I haven't decided yet" - in production code that decision should already be made: either a real request-scoped context was threaded through, or context.Background() at a genuine root (main, a test, init).
Forgetting defer cancel() after context.WithTimeout/WithCancelLeaks the timer/goroutine backing the derived context until the parent is done, not until the child call actually finishes.
Ignoring ctx.Err() inside a goroutine that keeps working after the caller gave upThe goroutine becomes exactly the kind of leaked/zombie goroutine described above - always select on ctx.Done() in any blocking operation.
Needing background work to outlive the request that spawned it (e.g. fire-and-forget audit log)Use context.WithoutCancel(ctx) (Go 1.21+) to keep request-scoped values while detaching from the parent's cancellation - don't reach for context.Background() and lose trace/request metadata, and don't skip cancellation handling by accident.

Testing Strategies (delta only)

  • testing/synctest (Go 1.25+) is now the preferred way to test concurrent code with deadlines/timeouts deterministically - it replaces time.Sleep-based synchronization in tests, which is inherently flaky under load.
  • go.uber.org/mock remains the actively maintained successor to the archived golang/mock; prefer hand-written fakes via consumer-defined interfaces first, go.uber.org/mock only when fakes get repetitive.
  • go test -race and table-driven tests are unchanged baseline practice - no version-driven change.

Sources


Maintenance note: re-check this document's verdicts whenever a new Go feature release ships (~February and ~August each year); a "current idiom" row here is only as current as the last verification pass.