Day 23 — Build Tags, Linting & Profiling

Updated

July 30, 2025

Day 23 — Build Tags, Linting & Profiling

Day 63 — Build tags & cross-compile

Stage VII · ~3h
Goal: Produce multi-OS/arch binaries deliberately—using //go:build constraints, GOOS/GOARCH, and a static-first default—and document what you shipped.

Note

Stage VII is ship quality. You stop treating “it builds on my laptop” as done. Artifacts, profiles, scans, and containers become first-class deliverables.

Why this day exists

Production Go almost always means more than one target:

  • CI runners on Linux/amd64 building for Linux/arm64 (Raspberry Pi, Graviton, Apple Silicon in Linux VMs)
  • Developers on macOS shipping Linux containers
  • Optional platform-specific code (syscall wrappers, OS-only features) without #ifdef chaos

Build tags and the cross-compile matrix are how the toolchain expresses that. Misusing cgo or ignoring tags is a classic “works here, fails in CI” class of bugs.


Theory 1 — Build constraints (//go:build)

Go files can be included or excluded at compile time with build constraints.

Modern syntax (preferred)

//go:build linux && amd64

package platform

Place the constraint before the package clause, with a blank line after. Multiple lines AND together when both appear (prefer a single line with && / || / !).

Common tag families

Constraint Meaning
linux, darwin, windows GOOS
amd64, arm64, 386 GOARCH
cgo / !cgo Whether cgo is enabled
Custom (//go:build integration) Your tags via -tags

Example — OS-specific files in one package

internal/fs/
  path.go           # shared API
  path_unix.go      # //go:build unix
  path_windows.go   # //go:build windows
// path.go
package fs

// OpenDataDir returns the platform data directory for the app.
func OpenDataDir(app string) (string, error) {
    return openDataDir(app)
}
// path_unix.go
//go:build unix

package fs

import "os"

func openDataDir(app string) (string, error) {
    home, err := os.UserHomeDir()
    if err != nil {
        return "", err
    }
    return home + "/.local/share/" + app, nil
}
// path_windows.go
//go:build windows

package fs

import "os"

func openDataDir(app string) (string, error) {
    base := os.Getenv("LOCALAPPDATA")
    if base == "" {
        return "", errNoLocalAppData
    }
    return base + "\\" + app, nil
}

Callers import package fs only. The linker sees one openDataDir per build.

Custom tags for optional features

//go:build e2e

package e2e_test
go test -tags=e2e ./...

Use tags for compile-time feature gates (integration tests, stub drivers). Prefer runtime config for product feature flags (Day 79).

Legacy // +build lines

You may still see:

// +build linux,amd64

Prefer //go:build. Both are still understood; do not mix confusingly in new code.


Theory 2 — Cross-compile with GOOS / GOARCH

Cross-compilation is a first-class Go feature for pure-Go (and many cgo-free) programs:

GOOS=linux  GOARCH=arm64 go build -o bin/app-linux-arm64 .
GOOS=darwin GOARCH=arm64 go build -o bin/app-darwin-arm64 .
GOOS=linux  GOARCH=amd64 go build -o bin/app-linux-amd64 .

List known pairs:

go tool dist list

What the env vars mean

Variable Role
GOOS Target operating system
GOARCH Target architecture
GOARM ARM generation (e.g. 7 for older 32-bit) when relevant
CGO_ENABLED 0 disables cgo (usually required for pure cross-compile)

Inspect a binary after build:

file bin/app-linux-arm64
go version -m bin/app-linux-arm64   # embedded build info / module versions

Theory 3 — cgo is the cross-compile cliff

If any package uses cgo (including transitive deps), cross-compiling needs a target C toolchain and correct CC. For most services and CLIs in this volume:

CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/app .
Choice Trade-off
CGO_ENABLED=0 Portable static-ish binaries; no glibc/musl host surprises
cgo enabled Need C libs; larger surface; hard cross-compile

SQLite note: pure-Go drivers (e.g. modernc.org/sqlite) vs CGO sqlite differ on this axis. Choose deliberately before containerizing (Day 68).

Trimpath and reproducible-ish builds

go build -trimpath -ldflags="-s -w" -o bin/app .
  • -trimpath strips local absolute paths from the binary
  • -ldflags="-s -w" strips symbol/DWARF tables (smaller; harder to debug)—use for release artifacts, not always for local pprof days

Embed version at link time:

VERSION=$(git describe --tags --always --dirty)
go build -ldflags="-X main.version=${VERSION}" -o bin/app .
package main

var version = "dev"

func main() {
    // print version in --version / /version
    _ = version
}

Theory 4 — //go:build vs runtime runtime.GOOS

Mechanism When to use
Build tags / file suffixes Different APIs, different syscalls, exclude whole files
runtime.GOOS / runtime.GOARCH Small branches in shared code

Prefer file split when half the file is OS-specific. Prefer runtime for a one-line path separator style choice already covered by path/filepath.


Worked example — multi-arch release script

#!/usr/bin/env bash
# scripts/build-release.sh
set -euo pipefail
APP="${APP:-day63}"
VERSION="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo dev)}"
mkdir -p bin
targets=(
  "linux/amd64"
  "linux/arm64"
  "darwin/arm64"
  "darwin/amd64"
)
for t in "${targets[@]}"; do
  os="${t%/*}"
  arch="${t#*/}"
  out="bin/${APP}-${os}-${arch}"
  echo "building $out"
  CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build \
    -trimpath \
    -ldflags="-s -w -X main.version=${VERSION}" \
    -o "$out" .
done
ls -la bin/

Minimal main.go for the lab:

package main

import (
    "fmt"
    "runtime"
)

var version = "dev"

func main() {
    fmt.Printf("%s %s/%s version=%s\n", "day63", runtime.GOOS, runtime.GOARCH, version)
}

Lab

Suggested workspace: ~/lab/90daysofx/01-go/day63

  1. Create a tiny module (example.com/day63) with the main above.
  2. Add one package with two OS-specific files (//go:build unix / //go:build windows or darwin/linux) that return a platform string used by main.
  3. Build at least linux/arm64 and darwin/arm64 (or linux/amd64 + your host)—with CGO_ENABLED=0.
  4. Record file output and go version -m for each artifact in NOTES.md.
  5. Optional: add scripts/build-release.sh and a custom tag file compiled only with -tags=debug.

Commands to keep

go env GOOS GOARCH CGO_ENABLED
go tool dist list | head
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/app-linux-arm64 .
file bin/app-linux-arm64
go version -m bin/app-linux-arm64

Common gotchas

Gotcha Fix
Cross-compile fails with cgo CGO_ENABLED=0 or install a cross C toolchain deliberately
Wrong file included Check //go:build line + blank line before package
Tag never applied Pass -tags=name to go build/go test
“It runs on my Mac binary in Linux” You shipped the wrong GOOS—check file
Debug symbols bloating CI artifacts Use -ldflags="-s -w" for release only
Import cycle from “platform helpers” Keep OS files in the same package; shared API in non-tagged file

Checkpoint

  • Explain //go:build placement and boolean operators
  • Produce two different OS/arch artifacts with CGO_ENABLED=0
  • Show file + go version -m evidence for both
  • At least one OS-specific source file pair (or documented reason you used runtime only)
  • NOTES.md lists targets and cgo policy

Commit

git add .
git commit -m "day63: build tags and cross-compile artifacts"

Write three personal gotchas before continuing.


Tomorrow

Day 64 — vet & staticcheck: make the module lint-clean and understand private module / replace hygiene before you optimize anything.


Day 64 — Vet, staticcheck & module hygiene

Stage VII · ~3h
Goal: Run go vet and staticcheck to a clean baseline, understand what each catches, and document private-module / replace discipline so CI can enforce quality without drama.

Why this day exists

Tests prove behavior you wrote assertions for. Static analysis catches whole classes of mistakes tests often miss:

  • Unreachable or suspicious control flow
  • Misused standard library APIs
  • Useless assignments, broken error checks, deprecated patterns
  • Module layout mistakes that only appear when someone else clones the repo

Ship quality means the gate is mechanical, not “I eyeballed it.”


Theory 1 — go vet (built-in)

go vet is part of the toolchain. It runs analyzers on packages you name:

go vet ./...

It is intentionally conservative: few false positives, limited depth. Examples of classic findings:

  • Printf format/arg mismatches
  • Unreachable code after return
  • Copying a mutex by value
  • Suspicious cancel() not called on context
  • Testing flags misuse

Not a full linter suite. Treat it as the free baseline every project runs.

Example — format bug vet can catch

package main

import "fmt"

func main() {
    id := 42
    fmt.Printf("user=%s\n", id) // vet: wrong type for %s
}
go vet .
# fmt.Printf format %s has arg id of wrong type int

Theory 2 — staticcheck (industry default)

staticcheck is the most widely adopted third-party static analyzer for Go. It groups checks as SAxxxx, STxxxx, SIxxxx, Uxxxx, etc.

Install (pin a version in real teams; for learning, current is fine):

go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck -version
staticcheck ./...

What it adds beyond vet

Area Examples
Correctness nil deref risk patterns, useless conditionals
Style / simplicity simplify boolean expressions, unused code
API misuse deprecated stdlib, wrong io usage
Performance hints some alloc / append patterns (not a substitute for benchmarks)

Configuration — staticcheck.conf

At module root:

# staticcheck.conf
checks = ["all", "-ST1000", "-ST1003"]

Or via CLI:

staticcheck -checks=all,-ST1000 ./...

Start with defaults, then disable only with a written reason. Blanket checks = [] is not a quality bar.

Example — SA4006 (ineffective assignment)

func normalize(s string) string {
    s = strings.TrimSpace(s)
    s = strings.ToLower(s)
    s = s // staticcheck may flag pointless reassignment patterns
    return s
}

More realistic: assigning err and overwriting without checking:

func load(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()
    _, err = f.Stat()
    // forgetting to check err here is a common bug class analyzers push you to fix
    return err
}

Theory 3 — Layering analysis in CI

Recommended order for this volume’s mental model:

go test ./...
go vet ./...
staticcheck ./...
govulncheck ./...     # Day 72

Optional later: golangci-lint as an orchestrator. Prefer understanding individual tools first so you can debug false positives.

Makefile / script sketch

#!/usr/bin/env bash
set -euo pipefail
go test ./...
go vet ./...
staticcheck ./...

Theory 4 — Module private / replace hygiene

GOPRIVATE / GONOSUMDB / GONOPROXY

Private modules must not hit the public proxy/sumdb:

go env -w GOPRIVATE=github.com/yourorg/*,example.com/internal/*
# often also:
# GONOSUMDB=$GOPRIVATE
# GONOPROXY=$GOPRIVATE

Without this, go mod download fails weirdly on private VCS or leaks intent to public infrastructure.

replace directives

// go.mod
module example.com/day64

go 1.26

require example.com/lib v1.2.3

replace example.com/lib => ../lib
Use Abuse
Local multi-module monorepo development Shipping replace to a laptop path in a public module
Temporary fork while waiting on upstream Permanent silent fork nobody notices

Rule of thumb: replace for local work is fine; CI must either vendor the story or use published versions. Document every replace in README.

go mod tidy discipline

go mod tidy
go mod verify

Tidy removes unused requires and adds missing ones. Commit go.mod and go.sum together.


Worked example — clean a sloppy package

Before (intentional issues for the lab):

package greeter

import (
    "fmt"
    "strings"
)

func Hello(name string) string {
    name = strings.TrimSpace(name)
    if name == name {
        // always true — staticcheck ST / SA style finding depending on version
    }
    return fmt.Sprintf("hello, %s", name)
}

func unused() {} // U1000 if unexported and unused

After:

package greeter

import "strings"

func Hello(name string) string {
    name = strings.TrimSpace(name)
    if name == "" {
        name = "world"
    }
    return "hello, " + name
}
go test ./...
go vet ./...
staticcheck ./...

Lab

Suggested workspace: ~/lab/90daysofx/01-go/day64 (or continue your Day 62 service module).

  1. Install staticcheck; record version in NOTES.md.
  2. Run go vet ./... and staticcheck ./... on a non-trivial module (prefer Stage VI service if you have it).
  3. Fix all findings or document suppressions with //lint:ignore only when justified (rare).
  4. Add a scripts/lint.sh (or Makefile target) that runs test + vet + staticcheck.
  5. Write 5–10 lines on your GOPRIVATE / replace policy for this repo (even if “none — all public modules”).

Stretch

  • Enable checks = ["all"] and triage noise.
  • Compare one finding that vet misses and staticcheck catches; paste both tool outputs in NOTES.

Common gotchas

Gotcha Fix
staticcheck not on PATH $(go env GOPATH)/bin must be on PATH
Different results in CI Pin staticcheck version; same Go version
Ignoring entire check families Prefer fixing code; disable one code with reason
Committing laptop replace Use published module or CI-visible path
go mod tidy fight One owner commits tidy; avoid partial edits
Treating lint green as “secure” Still run govulncheck (Day 72) and review auth

Checkpoint

  • go vet ./... clean
  • staticcheck ./... clean (or documented exceptions)
  • Lint script committed
  • Module policy note (GOPRIVATE / replace) written
  • Understand one SA/ST finding you fixed

Commit

git add .
git commit -m "day64: vet staticcheck module hygiene"

Write three personal gotchas before continuing.


Tomorrow

Day 65 — pprof: capture CPU and heap profiles and extract evidence before you rewrite anything for speed.


Day 65 — pprof CPU & heap

Stage VII · ~3h
Goal: Capture CPU and heap profiles from a running program or test, interpret the top frames with go tool pprof, and write a short evidence note with one optimization hypothesis.

Why this day exists

Without profiles, “performance work” is cosplay. pprof turns gut feel into:

  • Where CPU time went
  • What allocated on the heap
  • Which call stacks dominate

You already built services and CLIs (Stages V–VI). Today you learn to observe them the way production teams do.


Theory 1 — What pprof is

The runtime/pprof and net/http/pprof packages expose samples of program state:

Profile Answers
CPU Where did execution time go (sampled stacks)?
heap What is in memory / what allocated?
goroutine What are goroutines doing / blocked on?
mutex / block Contention and blocking (enable rates carefully)
allocs Allocation sites (related to heap view)

Profiles are samples and aggregates. They are not a perfect wall-clock trace (that is closer to tracing / otel on Day 70).


Theory 2 — Two capture styles

A) HTTP endpoints (net/http/pprof)

package main

import (
    "log"
    "net/http"
    _ "net/http/pprof" // registers on DefaultServeMux
)

func main() {
    // Prefer a dedicated debug mux/server in real services — never expose pprof publicly.
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()
    // ... real server on :8080 with its own mux
    select {}
}

Safer pattern: separate mux on loopback only:

mux := http.NewServeMux()
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
// heap, goroutine, etc. via pprof.Handler
go http.ListenAndServe("127.0.0.1:6060", mux)

Capture:

# 30s CPU profile
curl -o cpu.pb.gz "http://127.0.0.1:6060/debug/pprof/cpu?seconds=30"
# heap snapshot
curl -o heap.pb.gz "http://127.0.0.1:6060/debug/pprof/heap"

B) Tests and benchmarks

go test -cpuprofile=cpu.out -memprofile=mem.out -bench=. ./pkg
go test -cpuprofile=cpu.out -run=^$ -bench=BenchmarkHot ./...

Or programmatic:

f, _ := os.Create("cpu.prof")
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
// work...

Theory 3 — Reading profiles with go tool pprof

go tool pprof cpu.pb.gz
# interactive:
#   top
#   top -cum
#   list FuncName
#   web          # needs graphviz for full graph
#   png > cpu.png

Non-interactive:

go tool pprof -top cpu.pb.gz
go tool pprof -top -cum cpu.pb.gz
go tool pprof -list=hotFunction cpu.pb.gz
go tool pprof -http=:0 cpu.pb.gz   # local UI

How to read top

Column Meaning
flat Time/bytes in this function alone
cum Including callees
sum% Running total of flat

Start with top, then list the hottest symbols that are your code (not only runtime). Runtime frames are real but not always actionable on Day 1 of profiling.

Heap views

go tool pprof -top heap.pb.gz
# in interactive mode, common toggles:
#   inuse_space / inuse_objects
#   alloc_space / alloc_objects
  • inuse_*: currently held
  • alloc_*: cumulative allocations (great for “who allocates a lot even if freed”)

Theory 4 — A sane profiling workflow

  1. Reproduce load (loop, vegeta later on Day 80, or a benchmark).
  2. Capture CPU under that load.
  3. Capture heap under that load (and maybe at idle for comparison).
  4. Write the top 3 frames that look like your code.
  5. Form one hypothesis (“JSON marshal dominates; try pooling / smaller DTOs”).
  6. Change one thing tomorrow (Day 66 benchmarks) and re-measure.
Warning

Do not expose /debug/pprof on a public interface. It leaks internals and can be abused as a DoS vector. Bind to localhost or protect with network policy / auth.


Worked example — deliberate hot path

package hot

import (
    "encoding/json"
    "strings"
)

type Event struct {
    ID   string            `json:"id"`
    Tags map[string]string `json:"tags"`
}

func Process(raw []byte) (string, error) {
    var e Event
    if err := json.Unmarshal(raw, &e); err != nil {
        return "", err
    }
    // intentionally chatty string work
    var b strings.Builder
    for k, v := range e.Tags {
        b.WriteString(k)
        b.WriteByte('=')
        b.WriteString(v)
        b.WriteByte(';')
    }
    return b.String(), nil
}
// hot_test.go
package hot

import (
    "encoding/json"
    "testing"
)

func BenchmarkProcess(b *testing.B) {
    raw, _ := json.Marshal(Event{
        ID:   "x",
        Tags: map[string]string{"a": "1", "b": "2", "c": "3"},
    })
    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        if _, err := Process(raw); err != nil {
            b.Fatal(err)
        }
    }
}
go test -bench=BenchmarkProcess -benchmem -cpuprofile=cpu.out -memprofile=mem.out .
go tool pprof -top cpu.out
go tool pprof -top mem.out

Write FINDINGS.md:

# day65 findings
## load
go test -bench=BenchmarkProcess ...
## CPU top (mine)
1. encoding/json.Unmarshal
2. hot.Process
3. strings.(*Builder).WriteString
## hypothesis
JSON dominates; if this is a real hot path, consider a tighter encoding or reuse buffers.

Lab

Suggested workspace: ~/lab/90daysofx/01-go/day65

  1. Pick a target: Stage VI service or the mini hot package above.
  2. Capture a CPU profile (HTTP 20–30s under load or via -cpuprofile on a benchmark).
  3. Capture a heap profile.
  4. Produce FINDINGS.md with top-3 symbols + one hypothesis + commands used.
  5. Optional: go tool pprof -http=:0 screenshot or note of the flame-ish UI.

Service load without Day 80 tools

# crude load while CPU profile runs
while true; do curl -s localhost:8080/api/health >/dev/null; done

Common gotchas

Gotcha Fix
Profile of idle process Generate realistic load during capture
Optimizing runtime frames only Look for your package prefixes
Public pprof Localhost / auth / separate port
Confusing alloc vs inuse State which view you used in FINDINGS
Huge profiles in git Add *.pb.gz / *.out to .gitignore; keep notes
“I need to rewrite everything” One hypothesis; measure on Day 66

Checkpoint

  • CPU profile file produced
  • Heap profile file produced
  • go tool pprof -top interpreted for both
  • FINDINGS.md with top-3 + hypothesis
  • pprof exposure policy understood

Commit

git add .
git commit -m "day65: pprof cpu heap findings"

Write three personal gotchas before continuing.


Tomorrow

Day 66 — Benchmarks & allocs: turn today’s hypothesis into a measured microbenchmark and an evidence-based micro-optimization.