Go
Idioms for errors, concurrency and interfaces, the toolchain commands worth memorising, and the traps that survive code review.
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#
| Task | Command |
|---|---|
| Build for Linux, static | CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' ./cmd/api |
| Run tests with race detection | go test -race ./... |
| One test, verbose | go test -run TestName -v ./pkg/... |
| Coverage report | go test -coverprofile=c.out ./... && go tool cover -html=c.out |
| Benchmarks with allocations | go test -bench=. -benchmem ./... |
| CPU profile | go test -cpuprofile=cpu.out -bench=. then go tool pprof cpu.out |
| Profile a live server | go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30 |
| Vet and lint | go vet ./... && golangci-lint run |
| Tidy dependencies | go mod tidy && go mod verify |
| Upgrade one module | go get example.com/pkg@v1.4.2 |
| Why is this dependency here | go mod why -m example.com/pkg |
| Dependency graph | go mod graph | grep pkg |
| Vulnerability scan | govulncheck ./... |
| Escape analysis | go build -gcflags='-m' ./... 2>&1 | grep escapes |
| Update to a new Go version | edit 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 contextBound 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)
}| Trap | Reality |
|---|---|
| Unbuffered channel send with no receiver | Blocks forever; the goroutine leaks |
range over a channel nobody closes | Blocks forever |
| Closing a channel from the receiver | Panic on the next send; the sender closes |
| Loop variable captured in a closure | Per-iteration since Go 1.22; earlier versions share one variable |
sync.WaitGroup copied into a function | Copies the counter; pass a pointer |
| Mutex copied with its struct | go vet catches it; use pointer receivers |
| Reading a map from several goroutines while writing | Data 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) // falseReturn 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 runesA 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 requestshttp.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 reusedTesting#
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 changeKeep 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 changeGenerics#
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 workspaceBuild 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 storego test -tags=integration ./... # compile files constrained by //go:build integration
go vet -tags=integration ./... # vet the same set; untagged runs never see those filesembed 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 directlygolangci-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
- goimportsgolangci-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 configPin 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#
| Symptom | Cause | Check |
|---|---|---|
missing go.sum entry | go.mod changed without a tidy | go mod tidy, then commit go.sum |
fatal error: concurrent map ... | Concurrent read and write of a plain map | go test -race; guard with a mutex or use sync.Map |
| Test passes locally, fails in CI | Shared state or ordering between tests, or a real race | go test -race -count=10; remove global state |
| Goroutine count climbs without bound | A goroutine blocked on a channel or ignoring ctx | curl localhost:6060/debug/pprof/goroutine?debug=2 |
context deadline exceeded on every call | Timeout too short, or cancel() fires early | Check the WithTimeout value and the scope of defer cancel() |
| Binary far larger than expected | Debug symbols and DWARF included | Build with -ldflags='-s -w'; inspect with go tool nm -size -sort size |
go: updates to go.mod needed in CI | The build wants to change dependencies | go mod tidy locally; run CI with -mod=readonly |
| High GC CPU, sawtooth heap | Excess short-lived allocations | GODEBUG=gctrace=1; find escapes with -gcflags='-m', then pool or preallocate |
govulncheck reports a hit | A reachable call into a vulnerable symbol | go get pkg@fixed, then re-run govulncheck ./... |
pattern ... : no matching files found on //go:embed | Path is wrong, outside the package directory, or matched only dotfiles | Paths 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 constraint | go list -tags=integration -f '{{.TestGoFiles}}' ./pkg |
go build uses a local module version CI does not have | A go.work file in a parent directory | go env GOWORK; build with GOWORK=off |
unsupported version of the configuration from golangci-lint | v1 config with a v2 binary | golangci-lint migrate, then commit the version: "2" file |
cannot infer T | Type parameter appears only in the return type | Instantiate explicitly: Zero[string]() |
panic: test timed out after 10m0s | A test deadlocked on a channel or lock | Read the goroutine dump printed with the panic; add -timeout per package |
-race slows tests 5-10x and CI times out | Race detector overhead | Run -race on the concurrent packages only, or in a separate job with a longer timeout |
| Old test result reported after a fix | Cached test output | go 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.yamlSnippets#
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 typeHTTP 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 bufferAtomic 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() }