Go Anti-Patterns: What Fifty Projects Teach Us About What Not to Do#

Overview#

Anti-patterns are more instructive than patterns. A best practice tells you what to do when things are going well; an anti-pattern tells you what happens when something that looked reasonable turned out to be wrong. Across fifty-one major Go projects analyzed in this corpus—ranging from Kubernetes and CockroachDB to fzf and air, from 2012 to 2025—a surprisingly consistent set of failure modes appears. They are not random mistakes. They fall into seven families, each driven by a recurring misunderstanding of Go’s constraints.

The most important meta-observation: almost every anti-pattern in this corpus was correct for a while, then became wrong. Shutdown channels made sense before context.Context. Package-level config vars made sense before interfaces and injection were understood. Vendoring everything made sense before the module proxy was reliable. The pattern was not wrong at birth—it aged. The practitioner who learns from this corpus learns not just “don’t do X” but “X made sense when Y was true, and Y is no longer true.”

This chapter organizes the corpus’s anti-patterns into seven families, names the mechanisms that make each harmful, and identifies the projects where each appears so readers can study real implementations rather than constructed examples.


Family 1: Error Chain Destruction#

The most common error-handling anti-pattern in the corpus is so simple it barely seems like an anti-pattern: using %v instead of %w in fmt.Errorf. Buffalo uses fmt.Errorf("%v", err) throughout its error-wrapping paths. Terraform, predating Go 1.13, uses fmt.Errorf("%s", err). Both patterns silently destroy error chains. After the wrap, errors.Is and errors.As stop working—any caller who checks if errors.Is(err, ErrSpecific) downstream of these wrapping sites will never see the original error type.

The mechanism is subtle. fmt.Errorf("%w", err) creates a value whose Unwrap() method returns the original error; fmt.Errorf("%v", err) does not. The difference is invisible in logs—both formats print the same string—but programmatic inspection breaks. A bug introduced here is a debugging nightmare: the error messages look correct, but the code that routes on error identity silently takes the wrong path.

Beego takes this further with an actively harmful variant: encoding errors as formatted strings ("ERROR-{code}, {msg}") and parsing the string to recover the code. This bypasses Go’s error infrastructure entirely. The error cannot participate in errors.Is/errors.As chains, and is fragile to message format changes. When Beego needs to distinguish error categories at runtime, it string-parses its own messages—the inverse of what the type system is for.

A third variant appears in frp: serializing errors as plain strings in JSON at protocol boundaries. The string crosses the wire cleanly, but type information is destroyed. A client cannot distinguish “permission denied” from “resource not found” programmatically—it can only print the message. For simple tools, this is acceptable; for anything where caller error handling matters, it is a silent API contract violation.

The unifying principle: every time an error is wrapped, serialized, or returned as a plain value without preserving the original chain, every caller downstream loses the ability to make decisions on error identity. Go 1.13’s %w solved this in 2019. The anti-pattern persists only in code that predates the fix and has not been modernized.

The inverse mistake—using errors.As correctly but guarding on unexported concrete types rather than behavioral interfaces—appears in Delve’s pre-1.13 era code: if _, ok := err.(*typeConvErr); ok. This works until the error is wrapped; after wrapping, the type assertion fails silently. errors.As unwraps transparently; the direct type assertion does not. The idiomatic fix is three characters: replace the type switch with errors.As(err, &target).


Family 2: Goroutine Lifecycle Neglect#

Go makes goroutines cheap to start and expensive to forget. The corpus contains a spectrum from “no lifecycle management” (air, simple tools where it doesn’t matter) through informal patterns to elaborate supervisor trees. The anti-patterns cluster at the informal end.

Unbounded spawning without registration. Consul and Vault spawn goroutines with bare go func(){}() calls in task handlers and RPC paths without registering them in any lifecycle registry. Under load or during tests, goroutines accumulate. The shutdown function fires, the process exits, but goroutines still running do file I/O, hold locks, or write to channels. In tests, this causes flakiness—a goroutine from a previous test still running when the next test starts can corrupt shared state. In production, it causes shutdown hangs and OOM accumulation over days. CockroachDB’s Stopper, NATS’s startGoRoutine registry, and Temporal’s goro.Group all exist because their teams hit exactly this failure mode at scale.

Shutdown without drain. Closing a chan struct{} signals goroutines to stop; it does not wait for them to stop. Several HashiCorp projects (Consul, Vault, Nomad) close a shutdownCh and immediately proceed, returning from Stop() while goroutines are still running. The correct pattern pairs the close with wg.Wait(). Without the wait, the “stopped” process may still be committing data, closing network connections, or finalizing state. The fix is mechanical—add a sync.WaitGroup, call wg.Done() as each goroutine exits, and call wg.Wait() after closing the channel—but the omission creates a class of race conditions that appear only under load.

Worker pools without configurable bounds. Nomad and frp spawn goroutines proportional to workload with no explicit upper bound. Under adversarial or unexpected load, this degrades to a thundering herd: each incoming request spawns a goroutine, each goroutine competes for the same resources, each resource contention creates more goroutines to retry. The fix—a semaphore via buffered channel (sem := make(chan struct{}, N)) or an errgroup.SetLimit(N) call—adds at most five lines of code and converts unbounded fan-out to bounded fan-out. The cost is near zero; the protection is real.

The shutdownCh pattern without context. This is the most pervasive concurrency anti-pattern in the corpus, concentrated in the HashiCorp ecosystem: Consul, Vault, Nomad, and Terraform all use a single shutdownCh chan struct{} as the primary lifecycle signal. This predates context.Context (Go 1.7, 2016) and has not been modernized. The problem is that shutdownCh does not compose: passing cancellation to a library that expects context.Context requires awkward adapters, and the select { case <-shutdownCh: ...; case <-ctx.Done(): ... } pattern is repetitive throughout these codebases. New projects uniformly use signal.NotifyContext + context.WithCancel instead. The HashiCorp pattern is correct but frozen in time; the context model is more composable for everything added since 2016.


Family 3: Configuration Anti-Patterns#

Configuration is where Go projects accumulate the most long-term technical debt. Three anti-patterns dominate.

Package-level global config vars. Gogs and older Beego load configuration at startup into package-level variables (conf.Server.HTTPPort, conf.Auth.RequireSigninView) that callers read directly. The mechanism looks harmless until you need to test. Tests cannot inject different configurations without file system side effects—they must write a real config file and reload it. Multiple packages import the global config directly, creating hidden coupling: changing a field’s name or type requires updating every package that reads it, but no compiler error points to the callers. And if any test writes to these fields (for configuration variation), it must synchronize against other tests running in parallel. Tailscale explicitly built envknob to prevent the analogous pattern for env vars, enforcing lazy evaluation via sync.Once so env vars are never read in init(). The correct fix is injection: pass a *Config argument to each component’s constructor. It is more verbose but every dependency is explicit and testable.

The megastruct. NATS’s Options struct has 200 fields. K3s’s ServerConfig has 100+. Grafana’s setting.Cfg is injected into every service that runs. Large config structs are convenient at first—one struct, one place to add a field—and become a maintenance liability at scale. The problems compound: no single engineer understands all 200 fields and their interactions; fields added for one component accumulate in a struct read by every component; a poorly-typed field (a string where a net.IP was intended) propagates everywhere. The fix is sub-struct injection: Drone’s Wire-enforced pattern narrows the global config at build time so each service receives only its own config slice. Adding a Redis-specific field to the global struct cannot accidentally be read by the git package. The compiler enforces the isolation.

Mergo without zero-value tracking. Three projects (Moby, air, frp) use dario.cat/mergo to merge config structs from multiple sources. Mergo’s design has a fundamental limitation: it cannot distinguish “the user set this field to zero” from “the user did not set this field.” Moby hit this exact bug—a user who explicitly set a numeric option to 0 found their setting overwritten by the default, because mergo treated 0 as an empty/unset value. Moby added explicit “value-set” tracking to work around it. The lesson is not “don’t use mergo” but “mergo requires explicit handling of zero values if users can meaningfully set fields to their zero.” Any project using mergo for configuration merging should audit whether any field’s zero value is a meaningful setting, and add explicit Value-Set tracking for those fields.

Viper at scale. Viper is conspicuously absent from large, mature projects: Vault, Consul, Terraform, Kubernetes, etcd, Prometheus, Grafana, NATS, Syncthing, frp, Temporal, Delve—none use it. The reason emerges from the codebases that outgrew it: Viper’s string-keyed API (viper.GetString("database.host")) lacks type safety. Renaming a key is not caught by the compiler; typos in key names produce silent default values; the global singleton makes multiple isolated configurations awkward. For small-to-medium projects, Viper’s multi-source merge (flags → env vars → config file → defaults) is convenient. For large projects, the string keys become the dominant maintenance cost. The consistent pattern among projects that scaled: custom typed config structs with explicit field names and explicit merge order.


Family 4: Layout Anti-Patterns#

Project layout anti-patterns are structural rather than behavioral—they don’t cause crashes, they cause confusion and coupling that makes future work harder.

The util package. No other layout anti-pattern appears as consistently across the corpus. When a project has a package named util/, utils/, common/, or helpers/, it functions as a dumping ground: functions that don’t belong anywhere specific accumulate here. Over time, the package develops hidden couplings—util.ParseConfig depends on util.FileExists depends on util.Logger—until the package has more transitive dependencies than any domain package in the project. Refactoring it is expensive because the couplings are invisible (everything imports util, nothing exports to it). The fix is to ask, for each function in util, which domain package it belongs to. The answer is almost always clear: ParseConfig belongs in config, FileExists belongs in storage, Logger belongs in logging. Functions that genuinely belong nowhere are library candidates, not utility functions.

Giant single packages. Minio’s cmd/ package contains ~453 Go files. NATS’s server/ package contains ~180 files. These are the most extreme cases of a common pattern: a single package that absorbed too many responsibilities during rapid growth. The short-term benefit is real—no inter-package API decisions, no circular import errors, fast initial development. The long-term costs are also real: the package cannot be tested in pieces, changes to any file recompile the entire package, onboarding engineers must understand hundreds of files of context before making a change. The right decomposition is not always obvious, but the existence of the problem is: when a package has more files than can be navigated in a single session, it has too many responsibilities.

pkg/ as a meaningless wrapper. A pkg/ directory that mirrors the root directory structure (pkg/server/, pkg/client/) adds a path segment without adding information. It became a convention from early Java-esque project structures, spread through early Go projects, and is now widely recognized as cargo-culted indirection. The Go community’s current consensus, visible in most projects started after 2018, is: if a package is public, give it a meaningful name based on its domain; if it’s private, put it under internal/. The pkg/ layer in between serves no purpose.

No separation between generated and hand-written code. When generated protobuf stubs, sqlc query implementations, Wire-generated wiring, or mockery-generated mocks live alongside hand-written source in the same directory, git diff reviews become noisy, developers accidentally modify generated files, and linting must explicitly exclude generated files by pattern. Every project of significant scale separates them: headscale’s gen/, temporal’s api/, crush’s internal/swagger/, kubernetes’s zz_generated_*.go naming convention. The cost of the separation is one extra directory and a .gitignore exclusion; the benefit accumulates over every PR review for the project’s lifetime.


Family 5: Dependency Anti-Patterns#

Importing a large module for one package. Air imports github.com/gohugoio/hugo solely for hugo/watcher/filenotify—a small file notification utility. This pulls in Hugo’s entire dependency graph: 80+ transitive dependencies including Dart Sass bindings, WebP image processing, and markdown processors. Air’s go.sum expands from ~20 entries to ~105. Every developer who clones Air must download Hugo’s full dependency graph, every security scanner must audit Hugo’s transitive dependencies, and every go mod tidy must reconcile all 105 entries. The fix is mechanical: copy or re-implement the small utility, or extract it into a standalone minimal module. The lesson generalizes: before importing any large module, verify that you genuinely need more than 10% of its surface area. If not, the specific function you need is either small enough to inline or worth extracting.

Dual-library migration debt. Many projects in the corpus carry two versions of the same library simultaneously: Moby carries both gogo/protobuf and google.golang.org/protobuf; Vault carries both hashicorp/errwrap and native Go 1.13 error wrapping; Pop runs both lib/pq and pgx/v5; Temporal carries both urfave/cli v1 and v2. This state is common during migrations and typically stable for years because database and protobuf access code is high-risk to change. The anti-pattern is treating this state as permanent. The dual-library state represents ongoing friction: two sets of documentation to consult, two sets of patterns to follow, two sets of dependency version constraints to manage. The lesson is to budget migration completion time alongside migration start time—the hard part of a migration is the last 20%, not the first 80%.

The “own the critical path” pattern taken too far. Projects like MinIO, Caddy, frp, and Tailscale maintain surgical forks of upstream libraries for performance or correctness reasons. This is legitimate when the dependency is on the critical path and the upstream cannot or will not accept the needed change. The anti-pattern is extending this pattern to non-critical-path code or maintaining forks without a clear process for tracking upstream changes. Crush’s forked Anthropic/OpenAI SDKs (charmbracelet/openai-go, charmbracelet/anthropic-sdk-go) introduce a lag risk: new model capabilities and API changes require the Charm team to merge upstream before crush can use them. For a project centered on LLM integration, the API client is arguably on the critical path—but the fork should have a documented upstream-tracking strategy.


Family 6: Testing Anti-Patterns#

time.Sleep() for startup synchronization. Gin’s integration tests use time.Sleep(5ms) to wait for a server to start, despite a waitForServerReady() helper existing in the same file. This is the most common testing anti-pattern in the corpus. Sleep-based synchronization is brittle: on a slow CI machine, 5ms is insufficient; on a fast developer machine, it’s an unnecessary delay; under -race, the timing changes. The correct pattern is polling with exponential backoff and a timeout—precisely what waitForServerReady() implements. The anti-pattern persists because it works often enough to feel acceptable and fails only occasionally enough to seem like flakiness rather than a bug.

Port-pinned integration tests. Gin starts servers on fixed ports (:8080, :8443, :5150). When two tests run in parallel—or when CI retries a test, or when go test -count=2 runs the suite twice—these ports conflict and tests fail with address already in use. The fix is httptest.NewServer(handler), which assigns an ephemeral OS-chosen port. Every test that starts a server should use ephemeral ports; the only exception is tests that specifically test port configuration.

Mock drift from real service behavior. Drone’s sparse test coverage (approximately 8% test-file-to-source-file ratio) and heavily mocked infrastructure mean that mocked implementations may silently diverge from real service behavior as the service evolves. The mock of the database passes; the real database fails. The classic failure mode of mock-heavy testing is not that the mocks are wrong at creation—it is that they remain correct by definition even as the real implementation changes. The mitigation is contract tests: tests that run the same assertion suite against both the mock and a real instance, failing if they diverge. The projects with the most robust test suites (NATS, PocketBase, Fyne) achieve this by using real implementations in-process rather than mocks.

Testify suites with shared mutable state. Temporal identified this concretely: standard testify/suite shares a single suite instance across all Test* methods when run under suite.Run. If any test method modifies suite fields, those modifications persist into subsequent test methods. When test methods are run in parallel, this becomes a data race. Temporal built parallelsuite specifically to enforce a fresh suite instance per method via reflection. Any project using testify suites with t.Parallel() should audit whether suite fields are modified in any test method.


Family 7: API and Interface Anti-Patterns#

Anonymous middleware stacks. Most HTTP frameworks in this corpus (gorilla/mux, chi) compose middleware as a chain of closures: r.Use(authMiddleware, loggingMiddleware). This works but makes it impossible to inspect, override, or instrument individual middleware layers by name. When debugging a request, the stack is opaque—r.Use(func1, func2, func3) tells you nothing about what each function does, what order it runs, or which one rejected the request. PocketBase’s named hook approach (pbActivityLogger at priority -40, pbPanicRecover at -30) and Dapr’s middleware registration by string ID solve this, but the pattern has not spread to the broader framework ecosystem. The lesson for middleware-heavy applications: name your middleware functions descriptively, avoid anonymous closures in Use() calls, and consider priority-based ordering when insertion position matters.

Large public interfaces. PocketBase’s core.App interface has approximately 150 methods. The godoc comment explicitly notes it is “not intended to be implemented by third parties”—because implementing 150 methods for testing is infeasible. This is the inevitable result of growing a public interface organically: each feature adds a method, each method seems reasonable, and eventually the interface is too large to mock, too large to satisfy accidentally, and too large to reason about. The fix is narrow interfaces defined at each call site: instead of App with 150 methods, each consumer defines an interface with only the methods it uses. Tailscale’s SSH package defines ipnLocalBackend with ~10 methods rather than importing the full *LocalBackend. Moby’s router packages each define their own Backend interface with only the methods their routes need. The general principle: an interface defined by a provider (and expected to be implemented by consumers) should have as many methods as the contract requires. An interface defined by a consumer (and implemented by the provider) should have only the methods the consumer needs.

init() for route and command registration. Several projects register HTTP routes or CLI commands from init() functions: rclone adds subcommands to the global command tree in init(), Consul registers API routes in init(), the standard library’s net/http/pprof adds debug routes in init(). The problem is test isolation: init() fires once per process, before main(), and cannot be undone. A test that imports the pprof package gets its debug routes registered globally, whether or not the test needs them. A test binary that imports two packages registering conflicting routes fails at startup. The pattern also prevents multiple server instances in the same process—the second instance inherits all registrations from the first. Constructor-time registration (e.Use(middleware), app.AddCommand(cmd)) is always preferable because it is explicit, scoped to an instance, and reversible.


Cross-Cutting Observations#

The age signature#

Almost every anti-pattern in this corpus has an age signature. %v wrapping predates Go 1.13 (2019). shutdownCh patterns predate context.Context (2016). pkg/ directories predate community consensus on module layout (roughly 2018). Package-level global vars predate widespread interface-and-injection style (roughly 2015-2016). Projects that exhibit these patterns are not bad projects—they are old projects, or projects that adopted patterns from old projects without evaluating them in the current context. The most reliable signal that a pattern is an anti-pattern in disguise: it predates a standard library addition that directly addresses the same need.

The scale trigger#

A second class of anti-patterns is not wrong from the start but becomes wrong past a size threshold. The util package is fine at 10 functions; at 100 it is unmaintainable. Manual go func(){}() is fine for 5 goroutines; at 50, lifecycle tracking is necessary. A flat 50-field config struct is fine for a focused tool; at 200 fields, sub-struct decomposition is mandatory. These patterns are harmless until the project crosses a threshold, and the harm accumulates so gradually that it’s invisible until it isn’t. The right time to address them is earlier than it feels necessary—at 20 functions in util, not 100; at 20 goroutines without lifecycle tracking, not 50.

False anti-patterns#

Several patterns that look like anti-patterns in this corpus are deliberate, principled choices:

  • Gin deliberately avoids context.Context in its handler signatures—the project predates context and has made an explicit API stability decision not to add it.
  • Cobra does not use %w in its own error returns because errors from Cobra surface at the terminal, not in programmatic chains.
  • NATS uses stdlib flag rather than Cobra as a documented minimalism decision.
  • WireGuard-go has 5 direct dependencies, not because it hasn’t grown, but because “no new dependencies” is a security policy.

The difference between an anti-pattern and a principled exception is a written rationale. When CONTRIBUTING.md says “we do not add dependencies,” the absence of testify is a feature. When the code just doesn’t have testify with no explanation, it may be an omission. Before classifying something as an anti-pattern, check whether the project documented a reason.


A Practitioner’s Checklist#

The anti-patterns above reduce to a short diagnostic checklist. For a Go project under review, the highest-value questions are:

Error handling: Can every error wrapping site in the codebase be grepped for %v or %s instead of %w? Does any code parse error strings to extract type information? Are there protocol boundaries where errors are serialized as plain strings with no recovery path?

Concurrency: Is there a goroutine leak test (goleak, leaktest.AfterTest) in any package that starts goroutines? Does the shutdown path pair a close() with a WaitGroup.Wait()? Is there a configurable upper bound on parallel goroutines in any fan-out operation?

Configuration: Are there package-level variables that hold configuration? Does init() read environment variables? If the project uses mergo, does it track which fields were explicitly set to zero?

Layout: Is there a package named util, utils, common, or helpers? Is there a single package with more than 50 files? Is generated code mixed into the same directories as hand-written code?

Dependencies: Does go.mod import any module solely for a small utility package? Are there two versions of the same library in go.mod?

Testing: Do tests synchronize with time.Sleep? Do tests bind to fixed ports? Are there mocks for subsystems that could run in-process instead?

Interfaces: Does any public interface have more than 20 methods? Are there init() functions that register routes, commands, or metrics as a side effect of import?

A “yes” to any of these is not automatically a bug—context matters, and some of these patterns are defensible. But each is a flag worth investigating, and together they form a reliable picture of where a codebase carries technical debt.


Conclusion#

The fifty-one projects in this corpus represent a decade of Go production experience. Their anti-patterns are not the result of carelessness—they are the result of patterns that worked well at a different time or at a smaller scale, and were not revisited as circumstances changed. The practitioner who understands both the pattern and its failure mode is equipped to make the same choices consciously: to adopt Viper knowing it will become a liability at scale, or to avoid it from the start; to use manual goroutines knowing they will require lifecycle tracking at 50+, and to add that tracking before reaching 50.

Go’s explicitness—the language property that makes its best patterns so readable—also makes its anti-patterns uniquely trackable. Unlike languages where magic happens in frameworks, Go’s anti-patterns are usually visible in the code itself: the %v format verb, the package-level var, the go func(){}() without a WaitGroup. They can be found with grep, fixed mechanically, and tracked by convention. That is, ultimately, the meta-lesson of this corpus: Go code is honest about its debts. The question is only whether you read the bill.