Performance Rules: When to Optimize
Measure-first rules and anti-premature-optimization guidance.
Use when a PR claims speedups, when latency SLOs slip, or when reviewers suspect premature micro-optimization.
How to Use This Cheatsheet
- Require evidence (profile, benchmark, trace) before merging optimization-only PRs.
- Default to clear code unless a hot path is proven.
- Pair CPU profiles with allocation profiles - fixing allocs often beats algorithm tweaks.
- Re-benchmark after Go upgrades; compiler and GC behavior change.
Decision flow
| Step | Action | Stop if |
|---|---|---|
| 1 | Reproduce with realistic load | Cannot reproduce - fix observability first |
| 2 | go test -bench + -benchmem | Noise dominates - stabilize environment |
| 3 | CPU profile (pprof) | Hot func is not in your code - fix deps/config |
| 4 | Heap/allocs profile | Allocs are one-time startup - defer work |
| 5 | Trace (go tool trace) | Issue is IO wait - tune timeouts/parallelism |
| 6 | Code change + recorded delta | Regression in clarity without SLO gain |
When to optimize (green light)
| Signal | Likely lever | Verify |
|---|---|---|
| CPU profile >10% in one func | Algorithm, cache, precompute | CPU profile after |
High alloc_space in loop | Preallocate slice/map, strings.Builder | -benchmem |
| GC pause SLO miss | Reduce pointers, reuse buffers, smaller heaps | GODEBUG=gctrace=1 |
| RPC tail latency | Connection pooling, batching, fewer syscalls | Trace + histogram |
| JSON encode hot | json.Encoder reuse, smaller structs, codegen | Benchmark realistic payload |
When not to optimize (red light)
| Anti-pattern | Why wait | Instead |
|---|---|---|
| No profile attached | Guessing wastes review time | Profile first |
| Micro-optimizing cold paths | No SLO impact | Ship clarity |
unsafe without ADR | Safety and portability cost | Pure Go proof |
| Global object pools everywhere | Complexity and stale state | Pool only proven allocs |
| Disabling checks in prod | Security/reliability risk | Fix root cause |
Premature sync.Pool | Hard to reason | Measure allocs |
Code-level rules (after measurement)
| Rule | Apply when | Skip when |
|---|---|---|
| Preallocate slices | Size known from input | Rare append paths |
strings.Builder | Many concatenations in loop | Few joins - use + or Join |
| Pass pointers on large structs | Profiler shows copy cost | Small structs - prefer values |
json.Encoder buffer reuse | High QPS encode | One-off CLI output |
| PGO (profile-guided opt) | Stable workload binary | Fast-changing code paths |
| GOGC tuning | Latency after alloc fixes | Before reducing allocations |
Benchmark hygiene
go test -bench=BenchmarkFoo -benchmem -count=5 ./...
go test -cpuprofile=cpu.prof -bench=BenchmarkFoo ./...
go tool pprof -top cpu.prof| Rule | Requirement |
|---|---|
| Realistic input | Production-sized payloads |
| Reset timer | Expensive setup outside loop |
| Compare baseline | Branch A vs B in one PR |
| Document environment | CPU count, Go version, GOMAXPROCS |
| Track in CI lightly | Optional benchstat on release tags |
Service-level rules
| Area | Rule |
|---|---|
| HTTP | Set timeouts; reuse Transport with idle conn limits |
| DB | Pool size matched to concurrency; avoid N+1 queries |
| gRPC | Reuse connections; stream when payloads are large |
| Concurrency | Bound workers; profile under -race separately |
| Logging | Structured logs at info; debug off hot paths |
| Metrics | Low-cardinality labels |
GC and runtime (last resort)
| Knob | Use when | Caution |
|---|---|---|
GOGC | Proven allocation-bound pauses | Hides leaks |
GOMEMLIMIT | OOM risk with clear limit | May increase CPU |
| Green Tea GC (1.26 default) | New deployments | Validate on workload |
debug.SetMemoryLimit | Container-aware services | Test rollback |
Documenting exceptions
When bypassing a clarity rule for speed, PR description includes:
- Profile or benchmark link
- SLO or cost metric before/after
- Rollback plan if production regresses
FAQs
Is premature optimization really harmful?
It burns review time and adds bugs on cold paths.
Optimize when measurement shows user-visible impact.
When is readability allowed to lose?
When a hot path is proven and the team documents the trade-off with tests guarding behavior.
Should every PR include benchmarks?
Only performance PRs or APIs on critical paths.
Otherwise periodic profiling suffices.
CPU high but latency low?
May be batch/off-peak work.
Check tail latency histograms, not averages alone.
How do I avoid benchmark cheating?
Hoist setup, use realistic data, run -count=5, compare on same machine class.
Is escape analysis enough?
Compiler reports help.
Profiles confirm real-world allocation sites.
When to adopt PGO?
Stable binaries with representative production CPU profile fed into build.
Revisit each release.
Does TinyGo/WASM change rules?
Memory is tighter; clarity still first.
Measure on device targets, not desktop profiles alone.
What about sync.Pool?
Use when alloc profile shows repeated large temporaries.
Reset objects on Get; never store authoritative state only in Pool.
Should I tune GOGC in Kubernetes?
Only after alloc reduction and with memory limit aligned to container cap.
Monitor GC CPU overhead.
How does this relate to Effective Go?
Effective Go favors clarity.
These rules add when to deviate with evidence.
What is the fastest first step?
Use pprof on a representative benchmark or staging load, following the benchmark hygiene section above.
Related
- Table-Driven Tests and Subtests - benchmark structure
- Testing Best Practices - bench and race habits
- Effective Go Rules Checklist - clarity defaults
- Concurrency Rules Checklist - bounded workers
- Go Rules & Best Practices Summary - section index
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).