Skip to content

Idioms & Gotchas

The Go traps that bite newcomers, and the idioms that avoid them. Each entry follows the same shape — what goes wrong, why, and the fix — anchored to a real example from a production codebase. Throughout we use multigres (“Vitess for Postgres”), a set of small Go services in front of real PostgreSQL servers, as the running source of concrete code.

Many of these are caught automatically: the codebase runs golangci-lint with a large govet analyzer set, and a few custom lint rules on top. Each entry says whether CI catches it or whether it stays manual vigilance. The full linter map is at the end.

A nil concrete pointer boxed into an interface compares != nil. This is the single most surprising thing about Go interfaces.

typed-nil.go
func f() error {
var p *MyError // p == nil
return p // returns a NON-nil error!
}

An interface value is a (type, value) pair. (*MyError)(nil) has type *MyError and value nil — the pair is not the zero interface, so == nil is false. The interface only equals nil when both halves are empty.

A test in the mterrors package pins this down directly, comparing a real nil, an explicit (error)(nil), and a typed nil — only the last stays non-nil:

go/common/mterrors/errors_test.go
type nilError struct{}
func (nilError) Error() string { return "nil error" }
// ...
{ err: (*nilError)(nil), want: (*nilError)(nil) }, // typed nil is NOT nil

Loop-variable capture (historical, pre-Go 1.22)

Section titled “Loop-variable capture (historical, pre-Go 1.22)”

Before Go 1.22, closures and goroutines launched in a loop all captured the same loop variable, so they observed its final value.

loopvar.go
for _, v := range items {
go func() { use(v) }() // pre-1.22: every goroutine sees the LAST v
}

The cause was that the loop variable was declared once and reused each iteration. On Go 1.22+ each iteration gets a fresh variable, so the old v := v shadow is no longer needed. The codebase is on Go 1.25, so per-iteration variables are the default.

append may mutate a backing array shared with another slice, or hand back a slice that aliases its input.

append-alias.go
b := a[:2]
b = append(b, x) // may overwrite a[2] if cap(a) allows

Slices are { ptr, len, cap } headers over a shared backing array. append reuses the array when capacity permits and only allocates a new one when it must — so you can never assume from the call site whether the result aliases its input.

The fixes:

  • Force a fresh header when prepending: append([]T{x}, rest...).
  • Defensive copy: slices.Clone(s).
  • Always assign back: s = append(s, x) — and don’t assume append allocated.

Reading a nil map is fine (it returns zero values); writing one panics.

nil-map.go
var m map[string]int
_ = m["k"] // ok, returns 0
m["k"] = 1 // panic: assignment to entry in nil map

A nil map has no backing hash table: reads short-circuit to the zero value, but writes have nowhere to go. The fix is to make(map[K]V) before writing — typically right in the constructor. That’s exactly what the codebase does; its load balancer and recovery components initialize their maps up front (make(map[shardKey]...), make(map[gracePeriodKey]...)) so no method ever touches a nil map.

defer runs at function return, not at loop-iteration end — so deferred cleanups stack up and leak resources for the duration of a long loop.

defer-loop.go
for _, f := range files {
fh, _ := os.Open(f)
defer fh.Close() // every close waits until the function returns
}

Deferred calls push onto a per-function stack that unwinds only when the enclosing function exits. To fire defer per iteration, scope the work in a closure (or just close explicitly):

defer-loop-fixed.go
for _, f := range files {
func() {
fh, _ := os.Open(f)
defer fh.Close()
// ...
}()
}

A goroutine that blocks forever — on a channel, a never-cancelled context, or a non-terminating loop — never exits, leaking memory and resources. Nothing reclaims a blocked goroutine, and a context.WithCancel/WithTimeout whose cancel is never called leaks the context’s goroutine and timer too.

Two habits avoid almost all of these:

  • defer cancel() immediately after creating a cancellable context — the pattern you’ll see everywhere in the codebase: ctx, cancel := context.WithTimeout(...) then defer cancel().
  • Long-running loops select on <-ctx.Done() so they have an exit.

An inner := silently creates a new err, masking the outer one — so a later check reads the wrong variable and an error gets lost.

err-shadow.go
err := step1()
if cond {
x, err := step2() // NEW err, shadows the outer one
_ = x
}
return err // outer err — step2's error is silently lost

:= declares a new variable in the inner scope whenever at least one name on the left is new. The fix is plain discipline: use = (not :=) when reusing an existing err, or name the new value distinctly.

The timer behind time.After isn’t garbage-collected until it fires. In a hot loop, or on a branch that never selects it, these accumulate — each time.After allocates a time.Timer that the runtime holds until expiry.

The safe one-shot form puts it in a select that also watches <-ctx.Done(), so the timer is collected when the select returns:

go/services/multigateway/poolergateway/notification_adapter.go
select {
case <-ctx.Done():
return
case <-time.After(2 * time.Second):
}

For long-lived or repeated waits, prefer a reusable time.Timer/time.Ticker and Stop() it. A nice testability trick the codebase uses: wrap timers behind a small Timer interface (After(d) <-chan time.Time) whose real implementation calls time.After, so tests can inject a fake clock.

Copying a struct that embeds sync.Mutex, RWMutex, or WaitGroup copies the lock’s internal state, producing two locks that don’t coordinate.

copylocks.go
type S struct{ mu sync.Mutex }
func bad(s S) {} // copies the mutex — bug

Locks must be shared, not duplicated; a copied mutex carries its own stale state. The fix is to pass and hold such structs by pointer, and never assign them after first use. The codebase’s discovery component, for instance, embeds mu sync.Mutex and wg sync.WaitGroup by value and is only ever used through pointer receivers.

for range aMap visits keys in a randomized order that differs each run — a recipe for non-deterministic output and flaky tests. Go randomizes map iteration on purpose, precisely to stop code from depending on order.

The fix is to collect the keys, sort them, then iterate. The codebase ships a sortedmaps helper (Keys/Values/All for cmp.Ordered, ByStr for any comparable) that sorts before iterating, so callers don’t reimplement it each time.

Using a struct as a map key, or comparing two with ==, fails to compile if any field is non-comparable.

comparability.go
type K struct{ tags []string } // slice field
var m map[K]int // compile error: invalid map key type

A struct is comparable only if all its fields are comparable. Slices, maps, and funcs are not comparable, so a struct containing one isn’t either. Keep map-key structs small and all-comparable — strings, ints, bools, pointers, arrays of comparables. The load balancer’s shardKey is the model:

go/services/multigateway/poolergateway/load_balancer.go
type shardKey struct {
tableGroup string
shard string
}
// used as: leaders map[shardKey]*clustermetadatapb.LeaderObservation

See Types, Structs & Methods for the full rule.

Most — but not all — of these gotchas are caught in CI. This is the scorecard:

GotchaLinter / ruleStatus
Loop-variable capturecopyloopvar, govet:loopclosureenabled
append misuse (no values)govet:appendsenabled (aliasing not detected)
Missing cancel (ctx leak)govet:lostcancelenabled
Copying sync typesgovet:copylocks, govet:waitgroupenabled
Nil / impossible comparisonsgovet:nilness, govet:nilfuncenabled
sync/atomic misusegovet:atomicenabled
Unused writesgovet:unusedwriteenabled
Returning nil with non-nil errnilerrenabled (heuristic; //nolint:nilerr exceptions)
Map iteration orderruleguard requireSortedMapIteration / requireSortedMapsOverStdlibMapsscoped to consensus
Wall-clock in consensusruleguard disallowWallClockInConsensusscoped to consensus
Error shadowing with :=govet:shadowcommented out — manual vigilance
Typed-nil interfacenone — manual vigilance
Slice aliasingnone — manual vigilance

See Lint & Format for how these run.