Day 23 — Build Tags, Linting & Profiling
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:
Printfformat/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 intTheory 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=$GOPRIVATEWithout 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 verifyTidy 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 unusedAfter:
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).
- Install staticcheck; record version in
NOTES.md.
- Run
go vet ./...andstaticcheck ./...on a non-trivial module (prefer Stage VI service if you have it).
- Fix all findings or document suppressions with
//lint:ignoreonly when justified (rare).
- Add a
scripts/lint.sh(or Makefile target) that runs test + vet + staticcheck.
- Write 5–10 lines on your
GOPRIVATE/replacepolicy 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.pngNon-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 UIHow 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
- Reproduce load (loop, vegeta later on Day 80, or a benchmark).
- Capture CPU under that load.
- Capture heap under that load (and maybe at idle for comparison).
- Write the top 3 frames that look like your code.
- Form one hypothesis (“JSON marshal dominates; try pooling / smaller DTOs”).
- Change one thing tomorrow (Day 66 benchmarks) and re-measure.
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.outWrite 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
- Pick a target: Stage VI service or the mini
hotpackage above.
- Capture a CPU profile (HTTP 20–30s under load or via
-cpuprofileon a benchmark).
- Capture a heap profile.
- Produce
FINDINGS.mdwith top-3 symbols + one hypothesis + commands used.
- Optional:
go tool pprof -http=:0screenshot 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; doneCommon 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 -topinterpreted for both
FINDINGS.mdwith 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.