Go Language Expertise
Overview
Idiomatic, production-grade Go: concurrency, error handling, interfaces, testing, and version-gated behavior. The Foundations below get code working; the Expert Practices section is the higher bar — each item states the mechanism so you can apply it beyond the case shown.
Foundations
Error Handling
// Sentinel errors for identity checks; custom types for structured context.
var ErrNotFound = errors.New("resource not found")
type ValidationError struct{ Field, Message string }
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation on %s: %s", e.Field, e.Message)
}
func fetchUser(id string) (*User, error) {
u, err := db.GetUser(id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("user %s: %w", id, ErrNotFound) // wrap w/ %w
}
return nil, fmt.Errorf("fetching user %s: %w", id, err)
}
return u, nil
}
// errors.Is matches by identity down the %w chain; errors.As extracts a type.
var ve *ValidationError
if errors.As(err, &ve) { /* ve.Field, ve.Message */ }
%w vs %v is an API decision — see Expert Practices.
Concurrency
// Worker pool. Go 1.25+: wg.Go does Add(1)+launch+Done atomically (see Expert).
func workerPool(jobs <-chan Job, results chan<- Result, n int) {
var wg sync.WaitGroup
for range n {
wg.Go(func() { // pre-1.25: wg.Add(1); go func(){ defer wg.Done(); ... }()
for job := range jobs {
results <- process(job)
}
})
}
wg.Wait()
close(results) // sender closes, exactly once; never the receiver
}
// Context timeout — always defer cancel() even when the deadline fires.
func fetch(ctx context.Context, url string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body) // drain AND close — see Expert (conn reuse)
}
// select with nil-channel disable: a nil channel blocks forever, so setting a
// drained channel to nil removes it from the select without a sentinel flag.
select {
case v, ok := <-ch:
if !ok { ch = nil; continue }
use(v)
case <-ctx.Done():
return ctx.Err()
}
// RWMutex-guarded state. Pointer receiver is mandatory (copying a Mutex breaks it).
type SafeCounter struct {
mu sync.RWMutex
count map[string]int
}
func (c *SafeCounter) Inc(k string) { c.mu.Lock(); defer c.mu.Unlock(); c.count[k]++ }
func (c *SafeCounter) Get(k string) int { c.mu.RLock(); defer c.mu.RUnlock(); return c.count[k] }
Bounded concurrency with errgroup (preferred over hand-rolled fan-out): first non-nil error cancels the group's context and is returned by Wait; g.SetLimit(n) caps concurrent goroutines.
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
results := make([]Result, len(urls))
for i, url := range urls { // Go 1.22+: no `i, url := i, url` needed
g.Go(func() error {
r, err := fetchURL(ctx, url)
results[i] = r // distinct index per goroutine => no lock needed
return err
})
}
if err := g.Wait(); err != nil { return nil, err }
Mutex vs channel: use a Mutex to guard simple shared state (counters, caches, maps); use a channel to transfer ownership of data or coordinate goroutine lifecycles/pipelines. Don't reach for a channel where a mutex is plainly simpler.
Interfaces & Embedding
// Small interfaces; accept interfaces, return concrete types (see Expert).
type UserGetter interface {
GetUser(ctx context.Context, id string) (*User, error)
}
// Embedding for composition (promotes Base's fields/methods onto User).
type Base struct{ ID string; CreatedAt, UpdatedAt time.Time }
type User struct {
Base
Email, Name string
}
Functional Options
Variadic Option func(*T) closures over a zero-value-defaulted struct — the idiomatic way to give a constructor optional, backward-compatible parameters.
type Server struct{ host string; port int; timeout time.Duration }
type Option func(*Server)
func WithPort(p int) Option { return func(s *Server) { s.port = p } }
func NewServer(opts ...Option) *Server {
s := &Server{host: "localhost", port: 8080, timeout: 30 * time.Second}
for _, opt := range opts {
opt(s)
}
return s
}
// NewServer(WithPort(9000))
HTTP Servers
srv := &http.Server{
Addr: addr, Handler: mux,
ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second,
}
// ListenAndServe returns ErrServerClosed on graceful Shutdown — treat as non-error.
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { ... }
// Go 1.22+ method+wildcard routing in the stdlib mux — no framework needed for basics.
mux.HandleFunc("GET /api/users/{id}", h.GetUser) // id := r.PathValue("id")
mux.HandleFunc("POST /api/users", h.CreateUser)
// Recovery middleware — a panic in a handler otherwise kills the whole server.
func recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
slog.Error("panic", slog.Any("err", err), slog.String("path", r.URL.Path))
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
Frameworks (gin, echo, fiber) add routing/binding sugar over this; the timeout, graceful-shutdown, and recovery concerns are identical.
Database Access (sqlx)
type User struct {
ID string `db:"id"`
Email string `db:"email"`
}
func (r *Repo) GetByID(ctx context.Context, id string) (*User, error) {
var u User
err := r.db.GetContext(ctx, &u, `SELECT id, email FROM users WHERE id=$1`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return &u, err
}
// Transaction helper: rollback on error (report both errors), commit on success.
func (r *Repo) WithTx(ctx context.Context, fn func(*sqlx.Tx) error) error {
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
if err := fn(tx); err != nil {
if rb := tx.Rollback(); rb != nil {
return fmt.Errorf("rollback: %v (original: %w)", rb, err)
}
return err
}
return tx.Commit()
}
Always use the ...Context variants (GetContext, SelectContext, NamedExecContext) so queries honor cancellation.
Structured Logging (slog)
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
logger.Info("request", slog.String("method", r.Method), slog.Duration("latency", time.Since(start)))
logger = logger.With(slog.String("request_id", rid)) // derive a scoped child logger
Log errors with slog.Any("err", err), never slog.String(..., err.Error()) — see Expert. Hot paths: slog.LogAttrs avoids allocation.
Testing
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"positive", 2, 3, 5},
{"mixed", -1, 5, 4},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // subtests run concurrently; Go 1.22+ needs no `tt := tt`
if got := Add(tt.a, tt.b); got != tt.want {
t.Errorf("Add(%d,%d)=%d want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
Run go test -race ./... in CI — the race detector (~10x slowdown) is the primary tool for the memory-model gotchas below, but only reports races actually exercised at runtime, so races need test coverage to surface. In go < 1.22 modules the tt := tt capture workaround is required; go fix ./... removes it after a 1.22 bump.
Benchmarks
// Go 1.24+: for b.Loop() — setup runs once per -count (not b.N times), and the
// runtime keeps params/results alive so the compiler can't elide the body.
func BenchmarkSort(b *testing.B) {
data := generateData(1000) // runs once
for b.Loop() {
Sort(data)
}
}
Expert Practices: Idioms, Anti-Patterns & Gotchas
Design
Define interfaces in the consumer; return concrete types from the producer. Go interfaces are satisfied implicitly, so a producer never needs to declare the interface its types satisfy. Per Go Code Review Comments: interfaces "belong in the package that uses values of the interface type, not the package that implements those values," and "do not define interfaces before they are used." A broad producer-side interface (created for mocking) couples every consumer to that shape instead of the minimal method set it uses. Idiomatic: the producer returns a concrete type (often *T, frequently an unexported struct behind an exported func New() *myType) so new methods can be added without breaking callers; each consumer declares the small interface it needs. Returning an interface is a contract, not style: it forces the producer's whole method set onto every consumer and blocks additive change — a new method on the concrete type is unusable without widening the interface (a breaking change).
// consumer/service.go — declares exactly what it uses
type UserGetter interface {
GetUser(ctx context.Context, id string) (*User, error)
}
func NewService(g UserGetter) *Service { /* ... */ }
// producer/repo.go — returns a concrete type, idiomatic even when unexported
func NewUserRepository(db *sqlx.DB) *userRepository { /* ... */ }
Prefer synchronous functions; let callers add concurrency. Hiding goroutine creation forces concurrency on every caller — they lose control over lifetime, can't add timeouts, can't call synchronously, and testing is harder. The asymmetry is the point: making a sync function async is one line at the call site (go func(){ errs <- Process(ctx, item) }()); un-async-ing an async API requires rewriting every caller.
Design types so the zero value is usable. Every Go variable is zero-initialized; exploit it so a type needs no constructor and resists init-order bugs. sync.Mutex (zero = unlocked), bytes.Buffer, sync.WaitGroup all work at zero value. Corollaries: name bool fields so false is the safe default (prefer disabled over enabled); add a constructor only when init is genuinely non-trivial. Anti-patterns: requiring an Init() before use, or panicking on a zero value.
%w makes the wrapped error part of your public API; use %v at boundaries. fmt.Errorf("...: %w", err) lets callers errors.Is/errors.As the wrapped value, making it part of your contract. Wrap an internal sentinel like sql.ErrNoRows with %w and callers can depend on it — swapping your DB driver becomes a breaking change. Within an application, or when the sentinel is genuinely meant to be inspectable, use %w; at package/system boundaries (RPC, storage, external services) use %v to flatten to a string, or convert to your own exported sentinel first. Caveat: %v destroys chain identity — a later errors.Is against the original returns false with no compile error.
Use generics for type-identical code; use an interface when you only call methods. Reach for type parameters when you'd otherwise write the same code differing only by concrete type — containers and functions over slices/maps/channels of any element type. But (Go team's "When To Use Generics") "if all you need to do with a value is call a method on it, use an interface type, not a type parameter." func ReadSome[T io.Reader](r T) is strictly worse than func ReadSome(r io.Reader) — same speed, harder to read, no benefit.
Expose iteration with range-over-func (Go 1.23), not a full slice or a ForEach callback. Return iter.Seq[V] / iter.Seq2[K,V]; callers use ordinary for range. Beats returning []T (allocates the whole collection even on early break) and ad-hoc callbacks (non-standard, not composable). The stdlib adopted it (slices.All/Values, maps.Keys/Values, composing with slices.Collect/Sorted). Contract: the iterator must stop and return as soon as yield returns false.
Concurrency Gotchas
Goroutine lifetimes must be deterministic; never silently start a background goroutine in a library. Goroutines are not garbage collected — one blocked on a channel that never receives leaks for the life of the process. Code Review Comments: "make it clear when — or whether — goroutines exit." Treat lifetime as a contract: exit on ctx.Done() / a closed channel, or bound it to a function scope. The decision to start a background goroutine belongs to the application layer (main), not a library constructor.
// BAD: a library constructor that silently leaks
func NewCache(size int) *Cache {
c := &Cache{}
go c.evictionLoop() // never stops; leaks when the cache is abandoned
return c
}
The memory model gives no happens-before on goroutine exit or unsynchronized flags. Sequential consistency holds only for data-race-free programs. Starting a goroutine is synchronized before its first statement (writes before go f() are visible inside f), but goroutine exit carries no happens-before guarantee, and a flag read/written without a channel, mutex, or atomic has none. So for !done {} may loop forever (the value can sit in a register), and a write just before a goroutine exits is not guaranteed visible elsewhere. The WaitGroup/channel you observe completion through is the synchronizer — not termination itself. Use sync.Once, sync/atomic, a channel, or a mutex — and go test -race to catch violations.
WaitGroup.Add must run before go. Calling wg.Add(1) inside the goroutine races with wg.Wait() — Wait can return before the counter increments, stopping too early; the race detector does not reliably catch it. Increment before go. staticcheck flags this as SA2000. Go 1.25's wg.Go(func(){...}) does Add(1) + launch + deferred Done atomically — prefer it for new code.
Never copy a sync type after first use. Every sync primitive (Mutex, RWMutex, WaitGroup, Once, Cond, Map, Pool) holds internal state a copy silently invalidates. Most common trigger: a value receiver on a method of a struct embedding one — each call locks a copy, so the lock protects nothing. Always use pointer receivers on such types. go vet's copylock analyzer catches most cases (3-clause for coverage improved in Go 1.24).
func (c Cache) Set(k string, v int) { // BAD: value receiver copies the mutex
c.mu.Lock() // locks a copy; the real cache stays unprotected
defer c.mu.Unlock()
c.data[k] = v
}
Context
context keys must be an unexported package-local type, never a built-in. A string or other built-in key lets any package using the same literal read or shadow your value (the context docs forbid built-in key types). An unexported named type makes the type system guarantee cross-package uniqueness — two packages each declaring type ctxKey struct{} produce distinct, non-equal key types. A zero-size struct{} key allocates nothing.
type ctxKey struct{} // unexported; unique to this package
func WithUserID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, ctxKey{}, id)
}
Never store context.Context in a struct — pass it as the first argument. A context encodes the lifetime and cancellation scope of one logical operation; storing it makes it ambiguous which operations it governs, denies per-call deadlines, and invites leaks (Go team's "Contexts and structs"). The one accepted exception is retrofitting an existing API (as net/http.Request did), and even then duplicate methods (CallContext vs Call) are preferred. Corollary: don't drop the request context and start a fresh context.Background() inside helpers — thread ctx through every boundary.
Language Gotchas
The typed-nil interface trap: return the error interface, not a concrete error pointer. An interface value is nil only when both its type and value slots are unset. Assigning a typed nil pointer (var p *MyError = nil) to an error return makes the interface hold (T=*MyError, V=nil), which is non-nil — every if err != nil at the call site then fires even on success. Declare the return type as the error interface and return a bare untyped nil on success; never the concrete pointer. Applies to any interface. staticcheck flags the always-non-nil comparison as SA4023.
func returnsError() error {
if bad() {
return ErrBad // concrete value ONLY on the error path
}
return nil // bare untyped nil
}
defer evaluates arguments immediately; a named return lets a deferred closure mutate the result. defer f(x) evaluates x when the defer runs, not when f executes — so defer fmt.Println(i) captures i's current value. A deferred closure with no arguments captures by reference and sees later mutations. Combined with a named return, this is the idiomatic way to augment an error after return runs:
func doOp(id string) (err error) { // named return
defer func() {
if err != nil {
err = fmt.Errorf("doOp %s: %w", id, err) // mutates result after return
}
}()
return riskyOp(id)
}
defer in a loop queues until the function returns — the failure is FD exhaustion, not just memory. Every defer is queued until the enclosing function returns, so files opened in a loop stay open simultaneously and exhaust the OS file-descriptor limit long before the loop ends. Fix: extract a per-iteration function (preferred) or an inline IIFE so each defer runs per iteration.
func processFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // runs at end of THIS function, once per iteration
return process(f)
}
for _, path := range files {
if err := processFile(path); err != nil {
return err
}
}
A subslice shares the parent's backing array and spare capacity — cap it or copy. Reslicing never copies; a subslice inherits capacity extending into the parent's tail, so appending while capacity remains writes silently into the parent (no panic — just corruption). Two fixes: (1) the three-index full slice s[low:high:max] caps capacity to max-low so the first append beyond high reallocates — use it when returning a slice a caller will append to; (2) when keeping a small excerpt of a large buffer, copy into a fresh, exactly-sized slice so the large backing array can be GC'd.
parent := []int{1, 2, 3, 4, 5}
child := parent[1:3] // cap extends to end of parent
child = append(child, 99) // overwrites parent[3] silently -> [1 2 3 99 5]
func head(s []int) []int { return s[0:1:1] } // first append reallocates; can't reach parent
Log errors with slog.Any("err", err), not slog.String("error", err.Error()). slog's built-in handlers special-case error-typed Attr values — JSONHandler calls Error(), TextHandler uses fmt.Sprint — so pre-stringifying is unnecessary and lossy: it discards the concrete type a custom handler or LogValuer could inspect. ("err" is convention, not a documented standard — pick a key and be consistent.)
HTTP response bodies must be drained AND closed to reuse the connection. Per net/http: "If the Body is not both read to EOF and closed, the ... RoundTripper may not be able to re-use a persistent TCP connection." Close() after a partial read does not return a keep-alive connection to the pool; with the default DefaultMaxIdleConnsPerHost = 2, leaking connections under load causes new dials and timeouts. Always defer resp.Body.Close() and consume the body — io.ReadAll when you need it, io.Copy(io.Discard, resp.Body) when you don't.
Return errors, don't panic, for ordinary runtime failures. panic is for programmer bugs (invariant violations), not for a missing file or bad input. Recover only at process/goroutine boundaries (e.g. HTTP recovery middleware). A library that panics on ordinary failures forces every caller to defer-recover.
Version-Gated Behavior
Gated by the go directive in go.mod; older modules keep old behavior. The directive gates language and runtime semantics, not just the minimum toolchain — review release notes when bumping it.
Go 1.22 scopes for-loop variables per iteration — delete x := x. Before 1.22 a loop's variables were created once and mutated each iteration, so closures/goroutines/parallel subtests captured a shared variable; the fix was tt := tt. Go 1.22 creates fresh variables each iteration (all loop forms). In 1.22+ modules tt := tt is dead code go fix ./... removes. Hazard: bumping to 1.22 can make parallel subtests that passed only by reading the last iteration's value start failing — run GOEXPERIMENT=loopvar go test ./... first.
Go 1.23 made timer/ticker channels unbuffered — the drain-before-Reset idiom can now deadlock. Pre-1.23, timer channels had capacity 1, so a stale tick could buffer and the safe Reset idiom drained first. In 1.23 they are unbuffered and the runtime guarantees no stale value after Stop/Reset, so call Reset directly. An unconditional drain (<-t.C with nothing pending) now blocks forever; even if !t.Stop() { <-t.C } is no longer needed. Unstopped Timers/Tickers are now GC'd once unreferenced. (GODEBUG=asynctimerchan=1 reverts.)
Since Go 1.22 the global math/rand is ChaCha8Rand — but still use crypto/rand for secrets. Go 1.20 auto-seeds from OS entropy; 1.22 backs it with ChaCha8Rand, so accidental math/rand use is "no longer a security catastrophe" — but it is not a substitute for crypto/rand. Use crypto/rand for any secret (crypto/rand.Text() in 1.24+, or read rand.Reader); use math/rand/v2 (1.22+) for non-secret randomness. math/rand.Seed is deprecated and, if called, forces the weak Go 1 generator.
token := rand.Text() // crypto/rand, Go 1.24+: secret, base32, >=128 bits
idx := mathrand.IntN(len(items)) // math/rand/v2: non-secret
Modernize as part of the upgrade workflow — run go fix after bumping the directive:
go fix -diff ./... # preview
go fix ./... # apply
It rewrites interface{}→any, sort.Slice→slices.SortFunc, atomic free-functions→typed atomics (atomic.Int64/Bool/Pointer[T], which also fix 32-bit alignment footguns), the x := x removal, and context.WithCancel in tests→t.Context(). Also adopt: testing.T.Context()/B.Loop() (1.24), runtime.AddCleanup (1.24, preferred over SetFinalizer), and the slices/maps/cmp packages (1.21).
Naming
Package names: no stutter, no util/common/helper grab-bags. A package name is always visible at the call site, so a symbol repeating it reads redundantly: http.HTTPError, chubby.ChubbyFile. Pick names that form a natural phrase — http.Error, chubby.File, io.Reader. Second smell: util/common/helper/misc/base say nothing about contents, force import aliases, and accumulate unrelated code — split by domain instead.
Verification Checklists
Before completing any Go change
- [ ]
gofmt/goimportsclean;go vet ./...passes (catches copylock, printf, nil-func) - [ ]
go build ./...andgo test ./...green - [ ]
go test -race ./...on any code touching goroutines/shared state - [ ]
golangci-lint/staticcheckclean (SA2000 Add-before-go, SA4023 typed-nil) if configured - [ ] Every returned error is
%w-wrapped with context, or deliberately%v-flattened at a boundary - [ ] No ignored errors (
_ =) except deliberate, commented cases
Concurrency review
- [ ] Every goroutine has a deterministic exit (
ctx.Done(), closed channel, or bounded scope) — no library-level background goroutines - [ ]
synctypes are never copied (pointer receivers; no value passing of structs embedding them) - [ ] Shared state read/written only under a mutex/atomic/channel — no unsynchronized flags
- [ ] Channels closed by the sender exactly once; receivers never close
- [ ]
context.Contextis a first parameter, threaded through, never stored in a struct
API/interface review
- [ ] Interfaces declared in the consumer; constructors return concrete types
- [ ] Functions are synchronous unless concurrency is the caller's explicit request
- [ ] Zero value usable, or a constructor exists because init is non-trivial
- [ ]
errorreturn type is the interface; success path returns barenil(no typed-nil trap)