Software Engineering Wiki

Languages

Go

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

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 go directive, then go mod tidy

Errors #

An error is a value. Wrap it with context as it travels up, and compare with errors.Is/errors.As rather than string matching.

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

var pathErr *fs.PathError
if errors.As(err, &pathErr) { ... }
if errors.Is(err, context.DeadlineExceeded) { ... }

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

Wrap with what the caller cannot already know: the operation and its inputs. fmt.Errorf("error: %w", err) adds nothing.

Only wrap with %w when callers should be able to unwrap; %v keeps the message and hides the type, which is the right choice for an internal error you do not want in your API contract.

defer func() {
    if err := f.Close(); err != nil && retErr == nil {
        retErr = fmt.Errorf("close: %w", err)     // do not silently drop Close on writes
    }
}()

Context #

context.Context carries deadlines and cancellation, not optional parameters. It is the first argument, it is never stored in a struct, and it is never nil.

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

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

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

Every blocking call in a request path should take the context. A goroutine that ignores cancellation is a leak with extra steps.

Concurrency #

Goroutines are cheap; the coordination is what costs. Start a goroutine only when you know who waits for it and how it stops.

g, ctx := errgroup.WithContext(ctx)
for _, id := range ids {
    id := id                                    // pre-1.22 loop variable capture
    g.Go(func() error {
        return process(ctx, id)
    })
}
if err := g.Wait(); err != nil { return err }   // first error cancels the group
sem := make(chan struct{}, 8)                    // bounded concurrency
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 closureFixed in 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 writingRace; the runtime may panic outright

go test -race is not optional for concurrent code. It finds the bug that reproduces once a fortnight in production.

Interfaces and structure #

Define interfaces where they are consumed, not where the implementation lives. An interface with one implementation and one caller usually should not exist.

// 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 in a non-nil interface is not nil:

var p *MyError            // nil pointer
var err error = p         // interface holding a nil *MyError
fmt.Println(err == nil)   // false

Return concrete types and accept interfaces. Returning an interface hides information the caller may need and makes the zero value useless.

Slices, maps and strings #

s := make([]int, 0, 100)        // length 0, capacity 100: no reallocation for 100 appends
b := s[1:3]                     // shares the backing array — a write to b changes s
c := slices.Clone(s)            // independent copy
s = slices.Delete(s, 1, 2)      // shifts in place
clear(m)                        // Go 1.21+

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

append may or may not reallocate, so the result must always be assigned back. A slice of a large array keeps the whole array alive — slices.Clone when retaining a small piece of something big.

var b strings.Builder           // O(n) concatenation instead of O(n²)
for _, s := range parts { b.WriteString(s) }

HTTP services #

srv := &http.Server{
    Addr:              ":8080",
    Handler:           mux,
    ReadHeaderTimeout: 5 * time.Second,     // without this, one slow client holds a connection forever
    ReadTimeout:       15 * time.Second,
    WriteTimeout:      30 * time.Second,
    IdleTimeout:       60 * time.Second,
}

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

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

The default http.Client has no timeout: a hung server hangs your service. Always construct one.

client := &http.Client{
    Timeout: 10 * time.Second,
    Transport: &http.Transport{
        MaxIdleConnsPerHost: 100,
        IdleConnTimeout:     90 * time.Second,
    },
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)              // drain, or the connection is not reused

Testing #

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 subtest, t.Errorf continues. t.Cleanup beats defer in helpers, and t.TempDir removes itself.

func BenchmarkParse(b *testing.B) {
    b.ReportAllocs()
    for range b.N {                      // Go 1.22+ range over int
        _, _ = Parse(input)
    }
}

Modules and builds #

go mod init example.com/api
go get example.com/pkg@latest
go mod tidy                                   # add missing, remove unused
go mod vendor                                 # only when the build must be offline
go list -m -u all                             # available upgrades
go build -ldflags="-X main.version=$(git describe --tags)" ./cmd/api
GOFLAGS=-mod=readonly go build ./...           # fail if go.mod would change

Keep binaries in cmd/<name>/, importable code in package directories at the root, and anything that must not be imported by other modules in internal/.

Profiling #

import _ "net/http/pprof"
go func() { log.Println(http.ListenAndServe("localhost:6060", nil)) }()
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
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

Bind pprof to loopback or behind authentication. It exposes memory contents and stack traces.

Oneliners #

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

# Which packages depend on a module
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 package
go tool nm -size -sort size ./api | head -20

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

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

# Generate mocks or code and check it is current
go generate ./... && git diff --exit-code

Last updated 15 September 2026 · Edit this page