Software Engineering WikiSE Wiki

Go

Idioms for errors, concurrency and interfaces, the toolchain commands worth memorising, and the traps that survive code review.

Reviewed MarkdownEdit

On this page

Go trades expressiveness for a small language, a fast toolchain and a runtime that makes concurrency and garbage collection cheap. The idioms below are the ones that decide whether a service is correct under load: how errors carry context, how cancellation propagates, and how goroutines stop. Verify version-gated behaviour against go.dev/doc and package APIs against pkg.go.dev.

Cheatsheet#

TaskCommand
Build for Linux, staticCGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' ./cmd/api
Run tests with race detectiongo test -race ./...
One test, verbosego test -run TestName -v ./pkg/...
Coverage reportgo test -coverprofile=c.out ./... && go tool cover -html=c.out
Benchmarks with allocationsgo test -bench=. -benchmem ./...
CPU profilego test -cpuprofile=cpu.out -bench=. then go tool pprof cpu.out
Profile a live servergo tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
Vet and lintgo vet ./... && golangci-lint run
Tidy dependenciesgo mod tidy && go mod verify
Upgrade one modulego get example.com/pkg@v1.4.2
Why is this dependency herego mod why -m example.com/pkg
Dependency graphgo mod graph | grep pkg
Vulnerability scangovulncheck ./...
Escape analysisgo build -gcflags='-m' ./... 2>&1 | grep escapes
Update to a new Go versionedit the go directive, then go mod tidy

Errors#

An error is an ordinary value, not a thrown exception. A function returns one, the caller checks it, and each layer wraps it with context the caller could not already know: the operation and its inputs. Compare with errors.Is and errors.As rather than matching on the message string, which breaks the moment the wording changes.

if err != nil {
    return fmt.Errorf("fetch user %d: %w", id, err)   // %w keeps the wrapped error in the chain
}

var pathErr *fs.PathError
if errors.As(err, &pathErr) { /* inspect pathErr.Path */ }
if errors.Is(err, context.DeadlineExceeded) { /* the request timed out */ }

// A sentinel for a condition callers must branch on
var ErrNotFound = errors.New("not found")

fmt.Errorf("error: %w", err) adds nothing but noise. Wrap with %w only when callers should be able to unwrap and inspect the cause; use %v to keep the message but hide the type, which is right for an internal error you do not want to expose in your API contract.

A deferred Close on a writable file can fail after every write succeeded, so capture its error instead of discarding it:

defer func() {
    if cerr := f.Close(); cerr != nil && retErr == nil {
        retErr = fmt.Errorf("close: %w", cerr)
    }
}()

Context#

context.Context carries deadlines, cancellation and request-scoped values across API boundaries. It is not a bag for optional parameters. Pass it as the first argument, never store it in a struct, and never pass nil; use context.Background() at the top of a main or a test and derive from it.

ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()                                    // release resources even on the success path

req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)

select {
case <-ctx.Done():
    return ctx.Err()                              // Canceled or DeadlineExceeded
case res := <-ch:
    return res, nil
}

Every blocking call in a request path should take the context so it unwinds when the client goes away. A goroutine that ignores cancellation is a leak with extra steps.

Concurrency#

Goroutines are cheap to start; the coordination is what costs. Start one only when you can answer two questions: who waits for it to finish, and how does it stop. Use errgroup (from golang.org/x/sync/errgroup) when several tasks run together and the first failure should cancel the rest.

g, ctx := errgroup.WithContext(ctx)
for _, id := range ids {
    g.Go(func() error {
        return process(ctx, id)                   // loop variable is per-iteration since Go 1.22
    })
}
if err := g.Wait(); err != nil { return err }     // first non-nil error cancels the group's context

Bound fan-out with a buffered channel so a burst of work does not spawn unbounded goroutines:

sem := make(chan struct{}, 8)                     // at most 8 in flight
for _, job := range jobs {
    sem <- struct{}{}
    go func(j Job) { defer func() { <-sem }(); work(j) }(job)
}
TrapReality
Unbuffered channel send with no receiverBlocks forever; the goroutine leaks
range over a channel nobody closesBlocks forever
Closing a channel from the receiverPanic on the next send; the sender closes
Loop variable captured in a closurePer-iteration since Go 1.22; earlier versions share one variable
sync.WaitGroup copied into a functionCopies the counter; pass a pointer
Mutex copied with its structgo vet catches it; use pointer receivers
Reading a map from several goroutines while writingData race; the runtime may panic outright

go test -race is not optional for concurrent code. It instruments memory access and catches the bug that reproduces once a fortnight in production.

Interfaces and structure#

Define an interface in the package that consumes it, describing what that package needs, not what some type happens to provide. An interface with one implementation and one caller usually should not exist yet. Accept interfaces so callers can substitute a fake in tests; return concrete types so callers keep every method and a usable zero value.

// In the consumer package
type UserStore interface {
    Get(ctx context.Context, id int64) (*User, error)
}

func NewHandler(s UserStore) *Handler { return &Handler{store: s} }

A nil pointer stored in a non-nil interface is not equal to nil, which is a common source of “impossible” nil checks that never fire:

var p *MyError            // a nil pointer
var err error = p         // an interface value holding a nil *MyError, so it is non-nil
fmt.Println(err == nil)   // false

Return nil for the error, not a typed nil pointer, to avoid this.

Slices, maps and strings#

A slice is a view (pointer, length, capacity) over a backing array, so two slices can share storage and a write through one is visible through the other. append may reallocate, so always assign its result back. Strings are immutable bytes; ranging over one yields runes, not bytes.

s := make([]int, 0, 100)        // length 0, capacity 100: 100 appends without reallocating
b := s[1:3]                     // shares the backing array; a write to b changes s
c := slices.Clone(s)            // an independent copy
s = slices.Delete(s, 1, 2)      // removes index 1, shifting the tail down in place
clear(m)                        // empty a map without reallocating (Go 1.21+)

for i, r := range "héllo" {     // i is a byte offset, r is a rune
    _ = r
}
len("héllo")                    // 6 bytes
utf8.RuneCountInString("héllo") // 5 runes

A slice of a large array keeps the whole array alive, so slices.Clone when you retain a small piece of something big. For repeated concatenation, strings.Builder avoids the O(n²) of +:

var b strings.Builder
for _, s := range parts { b.WriteString(s) }
result := b.String()

HTTP services#

The standard library is enough for a production HTTP server, but it ships with no timeouts, so a slow or hostile client can hold connections open indefinitely. Set every server timeout, and give in-flight requests a bounded window to drain on shutdown. See HTTP timing for which phase to instrument when a request is slow.

srv := &http.Server{
    Addr:              ":8080",
    Handler:           mux,
    ReadHeaderTimeout: 5 * time.Second,     // time to read request headers; blocks Slowloris
    ReadTimeout:       15 * time.Second,    // time to read the entire request
    WriteTimeout:      30 * time.Second,    // time to write the response
    IdleTimeout:       60 * time.Second,    // keep-alive idle period
}

go func() {
    if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
        log.Error("server", "err", err)     // ErrServerClosed is the normal shutdown signal
    }
}()

<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)               // stop accepting, drain in-flight requests

http.Client also has no default timeout, so a hung upstream hangs your service. Construct one, and always drain and close the response body or the connection is not reused.

client := &http.Client{
    Timeout: 10 * time.Second,              // covers connect, redirects and reading the body
    Transport: &http.Transport{
        MaxIdleConnsPerHost: 100,
        IdleConnTimeout:     90 * time.Second,
    },
}
// per request:
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)              // drain remaining bytes so the connection can be reused

Testing#

Table-driven tests keep one assertion path and vary the inputs, so a new case is a new row. Run subtests in parallel where they are independent, compare structs with go-cmp for a readable diff, and match errors with errors.Is. See testing for choosing between table tests, golden files and fuzzing.

func TestParse(t *testing.T) {
    tests := []struct {
        name string
        in   string
        want Config
        err  error
    }{
        {name: "empty", in: "", err: ErrEmpty},
        {name: "valid", in: "a=1", want: Config{A: 1}},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            got, err := Parse(tt.in)
            if !errors.Is(err, tt.err) {
                t.Fatalf("err = %v, want %v", err, tt.err)
            }
            if diff := cmp.Diff(tt.want, got); diff != "" {
                t.Errorf("mismatch (-want +got):\n%s", diff)
            }
        })
    }
}

t.Fatalf stops the current subtest; t.Errorf records the failure and continues. t.Cleanup runs teardown in reverse order and works inside helpers where defer would fire too early; t.TempDir makes a directory that removes itself. Since Go 1.24, t.Context() gives a context cancelled just before cleanup.

Benchmarks should use for b.Loop() (Go 1.24+), which runs the body the right number of times and stops the compiler from optimising the call away:

func BenchmarkParse(b *testing.B) {
    b.ReportAllocs()
    for b.Loop() {
        _, _ = Parse(input)
    }
}

Modules and builds#

A module is the unit of versioning; go.mod declares the module path and the minimum Go version, and go.sum pins the checksum of every dependency. go mod tidy reconciles both with what the code actually imports.

go mod init example.com/api
go get example.com/pkg@latest                  # add or upgrade a dependency
go mod tidy                                     # add missing imports, remove unused ones
go mod vendor                                   # vendor deps into ./vendor for an offline build
go list -m -u all                               # show available upgrades for every module
go build -ldflags="-X main.version=$(git describe --tags)" ./cmd/api   # stamp the version
GOFLAGS=-mod=readonly go build ./...            # fail the build if go.mod would change

Keep executables in cmd/<name>/, importable packages at the module root, and code you never want other modules to import under internal/, which the toolchain enforces.

Profiling#

net/http/pprof exposes the runtime profiles over HTTP. Attach it to a debug listener bound to loopback, because it exposes memory contents and stack traces.

import _ "net/http/pprof"                        // registers handlers on the default mux
go func() { log.Println(http.ListenAndServe("localhost:6060", nil)) }()
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap        # heap, interactive
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30      # 30s CPU profile
curl -o trace.out 'http://localhost:6060/debug/pprof/trace?seconds=5' && go tool trace trace.out
GODEBUG=gctrace=1 ./api                          # GC pauses and heap growth on stderr, no code change

Generics#

Type parameters (Go 1.18+) let one function or type work over several types while staying statically checked. A constraint is an interface that lists the allowed types or methods; any allows everything, comparable allows ==, and golang.org/x/exp/constraints and the standard cmp package supply Ordered. Reach for generics when the alternative is copy-pasting a function per type or losing type safety through interface{}; do not reach for them to make an abstraction “flexible” before a second concrete type exists.

func Map[T, U any](in []T, f func(T) U) []U {
    out := make([]U, 0, len(in))
    for _, v := range in { out = append(out, f(v)) }
    return out
}

func Max[T cmp.Ordered](a, b T) T {            // cmp.Ordered: integers, floats, strings
    if a > b { return a }
    return b
}

type Number interface{ ~int | ~int64 | ~float64 }   // ~ admits named types whose underlying type matches

type Cache[K comparable, V any] struct {       // a generic type; methods cannot add their own type parameters
    mu sync.Mutex
    m  map[K]V
}
func (c *Cache[K, V]) Get(k K) (V, bool) { c.mu.Lock(); defer c.mu.Unlock(); v, ok := c.m[k]; return v, ok }

Since Go 1.23 a function with signature func(yield func(K, V) bool) is a range-over-func iterator, and the iter, slices and maps packages build on it: for k, v := range maps.All(m), slices.Sorted(maps.Keys(m)), slices.Collect(seq). Type inference covers most calls, so Map(ids, strconv.Itoa) needs no explicit instantiation; when it fails the error names the parameter it could not infer, and Map[int, string](...) fixes it.

Goroutine patterns#

Three shapes cover most concurrent code. A worker pool takes jobs from one channel and writes results to another, with a WaitGroup closing the results channel once every worker has returned. A pipeline chains stages by channel, each stage closing its output when its input closes. A fan-in merges several channels into one. In every shape the sender closes, the receiver ranges, and every blocking operation also selects on ctx.Done() so a cancelled request does not leave goroutines parked forever.

func pool(ctx context.Context, jobs <-chan Job, workers int) <-chan Result {
    results := make(chan Result)
    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for j := range jobs {                       // exits when jobs is closed
                select {
                case results <- work(ctx, j):
                case <-ctx.Done():
                    return
                }
            }
        }()
    }
    go func() { wg.Wait(); close(results) }()           // exactly one closer, after all senders finish
    return results
}

sync.Once initialises shared state exactly once; sync.OnceValue (Go 1.21+) wraps a function so its first result is memoised. singleflight.Group (in golang.org/x/sync) collapses concurrent identical requests, such as a cache miss storm, into one upstream call. Prefer a mutex around a plain map to sync.Map, which is tuned for append-only caches with disjoint key sets. time.After in a select inside a loop allocates a timer per iteration; use time.NewTimer and Reset, or a time.Ticker, when the loop is hot.

go test flags#

go test ./...                                    # every package; results are cached per package
go test -count=1 ./...                           # bypass the test cache
go test -run 'TestParse/valid' ./pkg             # regex on Test name, then on subtest name, separated by /
go test -skip 'TestSlow' ./...                   # Go 1.20+: inverse of -run
go test -short ./...                             # tests call testing.Short() to skip long paths
go test -timeout 2m ./...                        # panic with a goroutine dump when the package exceeds it (default 10m)
go test -shuffle=on ./...                        # randomise test order to expose ordering dependencies
go test -failfast ./...                          # stop after the first failing test
go test -v -json ./... | go tool test2json        # machine-readable events for CI dashboards
go test -fuzz=FuzzParse -fuzztime=30s ./pkg      # run a fuzz target; failing inputs land in testdata/fuzz/
go test -cover -coverpkg=./... ./...             # coverage of all packages, not just the one under test
go test -bench=. -benchtime=5s -count=6 ./pkg | tee new.txt && benchstat old.txt new.txt   # statistically compare

-race, -cover and -count invalidate the cache, so a CI job that combines them is never served stale results. TESTFLAGS does not exist; put shared flags in GOFLAGS (GOFLAGS=-race -shuffle=on) or a Makefile target.

Workspaces, build tags and embed#

go work lets several modules build against each other’s working copies without replace directives, which is how you test a library change against the service that consumes it before publishing. The go.work file is a developer convenience and belongs in .gitignore unless the repository is a deliberate monorepo.

go work init ./api ./lib            # creates go.work listing both modules
go work use ./tools                 # add a module
go work sync                        # push the workspace's chosen versions back into each go.mod
GOWORK=off go build ./...           # build as CI would, ignoring the workspace

Build constraints select files per platform or feature. A //go:build line must sit before the package clause with a blank line after it; filename suffixes (_linux.go, _amd64.go, _linux_amd64.go, _test.go) apply the same constraints implicitly.

//go:build linux && !integration

package store
go test -tags=integration ./...     # compile files constrained by //go:build integration
go vet -tags=integration ./...      # vet the same set; untagged runs never see those files

embed compiles files into the binary at build time so a service ships its migrations, templates and static assets in one artefact. Paths are relative to the source file’s directory, cannot contain .., and by default skip files beginning with . or _ (prefix the pattern with all: to include them).

import "embed"

//go:embed migrations/*.sql
var migrations embed.FS               // read with fs.ReadFile(migrations, "migrations/001_init.sql")

//go:embed VERSION
var version string                    // a single file may embed straight into a string or []byte

//go:embed all:static
var static embed.FS                   // include dotfiles under static/
http.Handle("/static/", http.FileServerFS(static))   // Go 1.22+: serve an fs.FS directly

golangci-lint#

golangci-lint runs many linters in one pass with shared parsing and caching. Version 2 (2025) changed the config format: the file must declare version: "2", linters.default picks the base set (standard, all, none, fast), and formatters (gofmt, gofumpt, goimports, gci) moved to their own formatters block and run with golangci-lint fmt.

# .golangci.yml
version: "2"
run:
  timeout: 5m
  tests: true
linters:
  default: standard                # errcheck, govet, ineffassign, staticcheck, unused
  enable:
    - bodyclose                    # HTTP response bodies left open
    - contextcheck                 # functions that drop the incoming ctx
    - errorlint                    # == on errors, %v where %w is needed
    - gosec
    - nilerr                       # returns nil after checking err != nil
    - sqlclosecheck
  settings:
    errcheck:
      check-type-assertions: true
  exclusions:
    paths:
      - third_party$
formatters:
  enable:
    - gofumpt
    - goimports
golangci-lint run ./...                  # lint; exit 1 on findings
golangci-lint run --new-from-rev=origin/main   # only issues introduced on this branch
golangci-lint fmt                        # apply the configured formatters
golangci-lint migrate                    # convert a v1 config to v2
golangci-lint linters                    # which linters are enabled with the current config

Pin the linter version in CI (golangci/golangci-lint-action with version: v2.x) so a new release does not fail the build with new checks on an unrelated day. Silence a single false positive with //nolint:gosec // reason on the line; a bare //nolint without a linter name is itself flagged by nolintlint.

Troubleshooting#

SymptomCauseCheck
missing go.sum entrygo.mod changed without a tidygo mod tidy, then commit go.sum
fatal error: concurrent map ...Concurrent read and write of a plain mapgo test -race; guard with a mutex or use sync.Map
Test passes locally, fails in CIShared state or ordering between tests, or a real racego test -race -count=10; remove global state
Goroutine count climbs without boundA goroutine blocked on a channel or ignoring ctxcurl localhost:6060/debug/pprof/goroutine?debug=2
context deadline exceeded on every callTimeout too short, or cancel() fires earlyCheck the WithTimeout value and the scope of defer cancel()
Binary far larger than expectedDebug symbols and DWARF includedBuild with -ldflags='-s -w'; inspect with go tool nm -size -sort size
go: updates to go.mod needed in CIThe build wants to change dependenciesgo mod tidy locally; run CI with -mod=readonly
High GC CPU, sawtooth heapExcess short-lived allocationsGODEBUG=gctrace=1; find escapes with -gcflags='-m', then pool or preallocate
govulncheck reports a hitA reachable call into a vulnerable symbolgo get pkg@fixed, then re-run govulncheck ./...
pattern ... : no matching files found on //go:embedPath is wrong, outside the package directory, or matched only dotfilesPaths are relative to the source file; use all: for ./_ names
Test file silently not compiled//go:build tag not passed, or a missing blank line after the constraintgo list -tags=integration -f '{{.TestGoFiles}}' ./pkg
go build uses a local module version CI does not haveA go.work file in a parent directorygo env GOWORK; build with GOWORK=off
unsupported version of the configuration from golangci-lintv1 config with a v2 binarygolangci-lint migrate, then commit the version: "2" file
cannot infer TType parameter appears only in the return typeInstantiate explicitly: Zero[string]()
panic: test timed out after 10m0sA test deadlocked on a channel or lockRead the goroutine dump printed with the panic; add -timeout per package
-race slows tests 5-10x and CI times outRace detector overheadRun -race on the concurrent packages only, or in a separate job with a longer timeout
Old test result reported after a fixCached test outputgo test -count=1, or go clean -testcache

Oneliners#

# Every exported symbol in a package
go doc -all ./pkg/store | grep -E '^func|^type'

# Which packages pull in a module and why
go mod why -m golang.org/x/net

# Build every main package in the repository
go build ./... && go list -f '{{if eq .Name "main"}}{{.ImportPath}}{{end}}' ./...

# Find heap escapes in hot code
go build -gcflags='-m -m' ./pkg/hot 2>&1 | grep 'escapes to heap'

# Test only packages that changed against main
go test $(git diff --name-only origin/main | grep '\.go$' | xargs -r -n1 dirname | sort -u | sed 's|^|./|')

# Fail the build on unformatted files
test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; }

# Race-test a single package repeatedly to catch flakes
go test -race -count=50 -run TestConcurrent ./pkg/queue

# Binary size by symbol
go tool nm -size -sort size ./api | head -20

# What the compiler inlined
go build -gcflags='-m' ./... 2>&1 | grep 'can inline'

# Module versions embedded in a built binary
go version -m ./api | grep dep

# Regenerate code and fail if the result is not committed
go generate ./... && git diff --exit-code

# Every test in the module that is not run in parallel
grep -rL 't.Parallel()' --include='*_test.go' .

# Which of my dependencies have a newer minor or patch release
go list -m -u -f '{{if .Update}}{{.Path}} {{.Version}} -> {{.Update.Version}}{{end}}' all

# Build once, then run the same binary under several inputs
go build -o ./bin/api ./cmd/api && ./bin/api -config dev.yaml

Snippets#

Retry with exponential backoff and jitter, stopping on context cancellation or a permanent error.

func retry(ctx context.Context, attempts int, fn func() error) error {
    var err error
    for i := 0; i < attempts; i++ {
        if err = fn(); err == nil || errors.Is(err, errPermanent) {
            return err
        }
        delay := time.Duration(1<<i)*200*time.Millisecond + time.Duration(rand.IntN(100))*time.Millisecond
        select {
        case <-time.After(delay):
        case <-ctx.Done():
            return fmt.Errorf("retry: %w (last: %v)", ctx.Err(), err)
        }
    }
    return fmt.Errorf("after %d attempts: %w", attempts, err)
}

Bounded parallelism with errgroup.SetLimit, so a slice of 10,000 jobs never has more than 16 in flight.

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(16)
for _, u := range urls {
    g.Go(func() error { return fetch(ctx, u) })
}
err := g.Wait()

Per-call timeout around a dependency, with the deadline visible in the error.

ctx, cancel := context.WithTimeoutCause(ctx, 2*time.Second, errors.New("db lookup budget"))   // Go 1.21+
defer cancel()
row, err := db.QueryRowContext(ctx, q, id)
if err != nil && context.Cause(ctx) != nil {
    return fmt.Errorf("lookup: %w", context.Cause(ctx))   // "db lookup budget" instead of a bare deadline error
}

Configuration from environment with defaults and validation, no third-party library.

type Config struct {
    Addr    string
    DBURL   string
    Timeout time.Duration
}

func Load() (Config, error) {
    c := Config{Addr: envOr("ADDR", ":8080"), DBURL: os.Getenv("DATABASE_URL")}
    d, err := time.ParseDuration(envOr("TIMEOUT", "5s"))
    if err != nil { return c, fmt.Errorf("TIMEOUT: %w", err) }
    c.Timeout = d
    if c.DBURL == "" { return c, errors.New("DATABASE_URL is required") }
    return c, nil
}

func envOr(k, def string) string { if v := os.Getenv(k); v != "" { return v }; return def }

HTTP client with a transport-level timeout for each phase, not just the whole request.

client := &http.Client{
    Timeout: 30 * time.Second,
    Transport: &http.Transport{
        DialContext:           (&net.Dialer{Timeout: 3 * time.Second}).DialContext,
        TLSHandshakeTimeout:   5 * time.Second,
        ResponseHeaderTimeout: 10 * time.Second,     // time to first byte of the response
        MaxIdleConnsPerHost:   50,
        IdleConnTimeout:       90 * time.Second,
    },
}

Decode a JSON response body with a size cap and unknown-field rejection.

dec := json.NewDecoder(io.LimitReader(resp.Body, 1<<20))   // refuse bodies over 1 MiB
dec.DisallowUnknownFields()
var out Payload
if err := dec.Decode(&out); err != nil {
    return fmt.Errorf("decode %s: %w", resp.Request.URL, err)
}

Graceful shutdown on SIGINT or SIGTERM with a drain deadline.

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

go func() { _ = srv.ListenAndServe() }()
<-ctx.Done()
stop()                                                     // restore default signal handling: a second Ctrl-C kills
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
    log.Error("shutdown", "err", err)                     // in-flight requests exceeded the deadline
}

Structured logging with log/slog: JSON to stderr, a request-scoped logger with fields attached once.

logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo, AddSource: true}))
slog.SetDefault(logger)

reqLog := logger.With("request_id", id, "path", r.URL.Path)
reqLog.InfoContext(ctx, "handled", "status", status, "duration", time.Since(start))
reqLog.Error("upstream", "err", err)                      // err renders as its message; wrap in slog.Any for the type

HTTP middleware that recovers panics and logs each request.

func logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        defer func() {
            if p := recover(); p != nil {
                slog.Error("panic", "err", p, "stack", string(debug.Stack()))
                http.Error(w, "internal error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
        slog.Info("request", "method", r.Method, "path", r.URL.Path, "ms", time.Since(start).Milliseconds())
    })
}

Method and path patterns in the standard mux (Go 1.22+), with path values.

mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    _ = id
})
mux.HandleFunc("POST /users", createUser)
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(static)))

A ticker loop that stops cleanly and never fires two overlapping runs.

t := time.NewTicker(time.Minute)
defer t.Stop()
for {
    select {
    case <-ctx.Done():
        return
    case <-t.C:
        if err := sweep(ctx); err != nil { slog.Warn("sweep", "err", err) }   // runs serially; a slow sweep skips ticks
    }
}

Table-driven test with a fake dependency and t.Context() (Go 1.24+).

type fakeStore struct{ users map[int64]*User }

func (f fakeStore) Get(_ context.Context, id int64) (*User, error) {
    u, ok := f.users[id]
    if !ok { return nil, ErrNotFound }
    return u, nil
}

func TestHandler(t *testing.T) {
    h := NewHandler(fakeStore{users: map[int64]*User{1: {Name: "ann"}}})
    for _, tt := range []struct{ path string; want int }{{"/users/1", 200}, {"/users/9", 404}} {
        t.Run(tt.path, func(t *testing.T) {
            req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, tt.path, nil)
            rec := httptest.NewRecorder()
            h.ServeHTTP(rec, req)
            if rec.Code != tt.want { t.Errorf("status = %d, want %d", rec.Code, tt.want) }
        })
    }
}

Golden-file comparison with an -update flag, so expected output lives beside the test.

var update = flag.Bool("update", false, "rewrite golden files")

func TestRender(t *testing.T) {
    got := Render(input)
    golden := filepath.Join("testdata", t.Name()+".golden")
    if *update { os.WriteFile(golden, got, 0o644) }
    want, err := os.ReadFile(golden)
    if err != nil { t.Fatal(err) }
    if !bytes.Equal(got, want) { t.Errorf("output differs from %s; run with -update to accept", golden) }
}

Read a large file line by line without loading it, with a raised buffer for long lines.

sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 1024*1024), 1024*1024)   // default token limit is 64 KiB
for sc.Scan() {
    line := sc.Text()
    _ = line
}
if err := sc.Err(); err != nil { return err }       // bufio.ErrTooLong when a line exceeds the buffer

Atomic file write: temp file in the same directory, fsync, rename.

func writeAtomic(path string, data []byte) error {
    tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
    if err != nil { return err }
    defer os.Remove(tmp.Name())                        // no-op after a successful rename
    if _, err := tmp.Write(data); err != nil { tmp.Close(); return err }
    if err := tmp.Sync(); err != nil { tmp.Close(); return err }
    if err := tmp.Close(); err != nil { return err }
    return os.Rename(tmp.Name(), path)
}

Run a subprocess with a timeout and capture both streams separately.

ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "pg_dump", "--format=custom", dbURL)
cmd.WaitDelay = 5 * time.Second                    // Go 1.20+: grace period before SIGKILL after ctx ends
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil {
    return fmt.Errorf("pg_dump: %w: %s", err, strings.TrimSpace(stderr.String()))
}

Custom error type that carries a status code and still supports errors.Is.

type HTTPError struct {
    Status int
    Err    error
}

func (e *HTTPError) Error() string { return fmt.Sprintf("http %d: %v", e.Status, e.Err) }
func (e *HTTPError) Unwrap() error { return e.Err }

// caller
var he *HTTPError
if errors.As(err, &he) && he.Status == http.StatusTooManyRequests { backoff() }