--- title: "Go Best Practices" description: "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." language: go framework: null category: language_best_practices tags: - best-practices - error-handling - goroutines - channels - context - concurrency - testing keywords: - errors.AsType vs errors.As - errors.Join vs go-multierror - sync.WaitGroup.Go goroutine leak - testing/synctest bubble - context.Context anti-patterns - goroutine leak production Uber LeakProf last_updated: "2026-07-08" difficulty: intermediate version: "Go >= 1.26" related: - ./README.md - ../rust/README.md - ../rust/best_practices.md - ../python/README.md - ../python/frameworks/fastapi/best_practices.md - ../../core/principles/solid.md - ../../core/architecture/clean_architecture.md - ../../core/devops/autonomous_least_privilege_environments.md search_priority: high status: published --- # Go Best Practices 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 | Situation | Outdated pattern | Current idiom (verified) | Why | |---|---|---|---| | Extract a typed error from a chain | `var pe *fs.PathError; if errors.As(err, &pe)` | `pe, ok := errors.AsType[*fs.PathError](err)` (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-multierror` | `errors.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 // 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 | Situation | Outdated pattern | Current idiom | Version | |---|---|---|---| | Spawn N goroutines, wait for all | `wg.Add(1)` before `go func(){ defer wg.Done(); ... }()` - a classic source of Add/Done mismatch panics | `wg.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 + cancellation | Hand-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 behavior | `time.Sleep(N * time.Millisecond)` then assert, or third-party `uber-go/goleak` | `testing/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 returns | Stable 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 process** | Manual `pprof` goroutine-count diffing over time | `runtime/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. | ```go 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. ```go 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-pattern | Why 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 signature | Hides 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 calls | Contexts 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 code | `TODO()` 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`/`WithCancel` | Leaks 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 up | The 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 - [Go 1.26 Release Notes](https://go.dev/doc/go1.26) - `errors.AsType`, self-referential generics, Green Tea GC, `goroutineleak` profile - [errors.AsType feature writeup and benchmarks](https://antonz.org/accepted/errors-astype/) - [errors: AsType proposal, golang/go#51945](https://github.com/golang/go/issues/51945) - [Go 1.25 Release Notes](https://go.dev/doc/go1.25) - `testing/synctest`, container-aware `GOMAXPROCS`, `sync.WaitGroup.Go` - [Testing concurrent code with testing/synctest](https://go.dev/blog/synctest) - [Detecting goroutine leaks with synctest/pprof](https://antonz.org/detecting-goroutine-leaks/) - [Uber: LeakProf - Featherlight In-Production Goroutine Leak Detection](https://www.uber.com/en-IN/blog/leakprof-featherlight-in-production-goroutine-leak-detection/) - [uber-go/goleak](https://github.com/uber-go/goleak) - [errors.Join vs go-multierror](https://dev.to/gabrielanhaia/errorsjoin-vs-multi-return-when-to-aggregate-when-to-wrap-3b5o) - [Pitfalls of context values](https://www.calhoun.io/pitfalls-of-context-values-and-how-to-avoid-or-mitigate-them/) - [Fixing For Loops in Go 1.22](https://go.dev/blog/loopvar-preview) - [Container-aware GOMAXPROCS](https://go.dev/blog/container-aware-gomaxprocs) --- **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.