Languages
Go
Idioms for errors, concurrency and interfaces, the toolchain commands worth memorising, and the traps that survive code review.
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 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 groupsem := make(chan struct{}, 8) // bounded concurrency
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 | Fixed in 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 | Race; 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) // falseReturn 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") // 5append 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 requestsThe 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 reusedTesting #
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 changeKeep 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 stderrBind 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