| name | go-perf-audience-and-tradeoffs |
| description | Guides framing a Go performance decision by audience — the same change is right for one and wrong for another: the library author who can't profile callers and must not pessimize the common case (offer AppendXxx/[]byte forms, no unsafe in the API), the app developer who can profile and optimizes the proven hot path, the SRE who needs the code diagnosable (pprof labels, metrics, GOMEMLIMIT), the next reader who pays for clever micro-opts; the clarity-vs-speed through-line. Fires on "should a library do this", "is this worth optimizing for my users", "make this diagnosable", "premature optimization vs pessimization", "who am I optimizing for". Routes the policy root to go-idiomatic-discipline, measure-first to go-perf-methodology. |
| allowed-tools | Read, Glob, Grep |
| compatibility | Claude Code, Codex CLI, Gemini CLI |
Go Performance: Audience & Tradeoffs
"Clear is better than clever." · "A little copying is better than a little dependency."
— Go Proverbs
"Optimization is a form of refactoring … This improvement generally comes at the cost of readability."
— go-perfbook
The people affected by a Go performance change are not one audience, and the right call depends on who you are writing for. The library author and the application developer make opposite correct calls on the same line: one cannot profile the caller and must not pessimize the common case; the other can profile and should optimize only the proven hot path. Before you trade clarity for speed, name your audience. This skill owns that framing; depth routes to the go-perf-* leaves and the policy root.
The measure-first loop is go-perf-methodology ("no profile, no trade"); the dual-axis "clear AND correct" policy is go-idiomatic-discipline. Read those first. All Go below builds clean under gofmt, go vet, and go test on Go 1.25/1.26.
1. The Four Audiences
| Audience | Can they profile the real workload? | What "performance" means to them | The failure |
|---|
| Library / package author | No — your callers' workload is unknown | Don't pessimize the distribution of callers; offer a zero-alloc form | Forcing an allocation on every caller; unsafe in the public API |
| Application / service dev | Yes — profile your own prod | Optimize the proven hot path; ignore the cold 99% | Micro-tuning an unmeasured / cold path |
| SRE / on-call at 3am | Only in prod, under fire | The code must be diagnosable | Anonymous frames, no labels, no metrics, OOM instead of GC |
| Next reader of the code | n/a | Every micro-opt is a tax they pay forever | A clever unsafe trick saving 3ns on a cold path |
The unifying lesson: the same change is right for one audience and wrong for another. §2–§5 take each in turn; §6 is the through-line that connects them.
2. The Library / Package Author — Optimize the Distribution of Unknown Callers
You cannot profile your callers' workload — there is no single workload. So the library author's job is not to optimize one path; it is to not foreclose performance for the distribution of unknown callers, while keeping the API clear. go-perfbook is explicit about the danger of over-fitting: "avoid the temptation to optimize upfront for every single use case. This will result in unreadable code … You can neither read minds nor predict the future" (go-perfbook).
The concrete disciplines — each a thing the stdlib itself does:
- Don't force an allocation on the common case. Accept
[]byte (or io.Writer) so a caller who already has bytes need not copy into a string. Your hot path is your caller's hot path.
- Offer the zero-alloc
Append form. The caller controls the buffer. strconv.AppendInt(dst []byte, …) []byte exists alongside strconv.Itoa precisely so a hot caller appends into a reused buffer with zero allocations (strconv); time.Time.AppendFormat, fmt.Append*, encoding/hex.AppendEncode follow the same convention.
- Keep the zero value useful.
bytes.Buffer and strings.Builder are "ready to use" at zero value — no constructor allocation forced on the caller (strings.Builder). "Make the zero value useful" (Proverbs).
- Document allocation behavior. If a method allocates per call, say so; a caller optimizing their hot path needs it as a documented contract, not a thing they rediscover in a profile.
- Do NOT expose
unsafe in the public API. unsafe.String/unsafe.Pointer may live inside an implementation behind a safe, copying-by-contract signature — never in the exported type. The caller cannot audit your aliasing invariants.
- Do NOT over-fit to one caller's microbenchmark. A 3% win on one reported workload that complicates the API taxes every other caller. go-perfbook's out: "a reasonable answer might be 'Then use this other package over here'."
func (f *Formatter) Int(n int) string { return strconv.Itoa(n) }
func (f *Formatter) AppendInt(dst []byte, n int) []byte { return strconv.AppendInt(dst, n, 10) }
API-level allocation patterns (Append, []byte, buffer reuse) → go-perf-strings-bytes-zerocopy and go-perf-slices.
3. The Application / Service Developer — You Can Profile, So Measure First
The application developer has the one thing the library author lacks: a real workload you can profile. So measure-first applies fully — optimize the path the profile proves hot, and ignore the cold 99%. Your tradeoff is clarity vs speed on code your own team maintains, so the bar is "did the profile rank this in the top of -cum, and does benchstat confirm the win?" — not "could this be faster in principle."
This is the audience for which go-perf-methodology's entire apparatus exists: USE to find the saturated resource, the right profile for the question, benchstat n≥10, Amdahl as the cold-path guard. "Doubling speed in a routine consuming 5% of runtime yields only 2.5% total" (go-perfbook). The proof obligation is on you — and you can meet it, so you must.
One inversion to watch: the app developer is also a library author to their own internal packages. A shared internal/ helper called from many services has callers you can't all profile from one place — treat its API by §2's rules even though the binary is "an app." The audience is set by who calls the code, not by whether the repo ships a binary or a module.
4. The SRE / On-Call Engineer — "Performance" Includes Diagnosability
The engineer paged at 3am cannot refactor; they can only observe. To them "performance" means the running service is diagnosable under load — and that is a property you build in at authoring time, for free, with no clarity cost:
- Meaningful names in the profile. A flame graph full of
func1, (*T).bound-method, and giant inlined anonymous closures is unreadable. Named functions and avoiding needless closures make the profile self-describing. (No tradeoff here — clear names are also idiomatic.)
pprof labels on request types. pprof.Do(ctx, pprof.Labels("endpoint", "/checkout"), …) lets the on-call slice one CPU profile by request type instead of staring at an undifferentiated blob → go-perf-pprof-profiling (labels).
- Metrics exposed.
runtime/metrics histograms (/gc/*, /sched/latencies:seconds) surfaced via expvar/Prometheus turn "it feels slow" into a number → go-perf-godebug-and-metrics.
GOMEMLIMIT set so the pod GCs instead of OOMing. A soft memory limit makes the runtime collect harder under pressure rather than getting OOM-killed — the difference between a latency blip and a crash loop the SRE has to firefight → go-perf-gc-tuning.
- Tail metrics, not the mean. The SRE lives on p99/p999; report them →
go-perf-tail-latency.
The diagnosable service and the fast service are not in tension: labels, metrics, and a memory limit cost nothing in clarity and pay off exactly when a human is under the most pressure.
This is the audience whose needs are most often omitted rather than wrongly traded: nobody argues against diagnosability, it simply isn't built because the author at write time isn't the one paged. The asymmetry is the point — an unmeasured micro-opt (§5) is clarity spent for nothing, but a pprof label is observability bought for nothing. When in doubt about a perf change, the diagnosability additions are the ones you make without needing a profile to justify them.
5. The Next Reader of the Code — Every Micro-Opt Is a Tax
The most common audience, and the one never present at write time, is whoever reads this code next. Every micro-optimization is a tax they pay on every future change. "Optimization is a form of refactoring … at the cost of readability" (go-perfbook) — so an unmeasured micro-opt spends the reader's budget and buys nothing.
The sharp case is the clever unsafe trick that saved 3ns on a cold path: it is not a speedup, it is a latent bug (an aliasing or lifetime violation the race detector and go vet can't see) that every future reader must re-audit before touching the function. "Clear is better than clever" is an empathy rule, not an aesthetic one (Proverbs). The owner of this axis is the policy root → go-idiomatic-discipline.
6. The Through-Line: Premature Optimization vs Premature Pessimization
Every audience above is a point on one axis: clarity vs speed. It is bounded on both sides, and naming the audience tells you which bound you're near.
- Premature optimization (the ceiling): trading clarity for an unmeasured speed gain. Knuth's caution, and
go-perf-methodology's "no profile, no trade." The app dev who micro-tunes a cold path is here; so is the library author who over-fits one caller's benchmark.
- Premature pessimization (the floor): "writing code that is slower than it needs to be, usually by asking for unnecessary extra work, when equivalently complex code would be faster and should just naturally flow out of your fingers" (Herb Sutter, via go-perfbook). The library that allocates where a
[]byte-accepting signature wouldn't is here — not because it's unmeasured, but because the clear version is also the fast one.
The rule is not "never think about performance." It is: spend clarity only when an audience that can prove the win asks for it. The library author avoids pessimizing the common case (a free win, no clarity cost). The app dev pays clarity only for a profiled hot path. The SRE's needs cost no clarity at all. The reader pays for everything else. This is exactly go-idiomatic-discipline's dual axis ("clear AND correct") and go-perf-methodology's "no profile, no trade," applied per-audience.
7. The Opposite-Call Example
The same line — "should this function allocate a result string?" — has opposite correct answers by audience:
func AppendQuoted(dst []byte, s string) []byte { }
func bootBanner(v string) string { return "starting " + v }
The library author who returns a plain string here pessimizes thousands of unknown hot callers. The app dev who writes an Append form for a boot-time banner has paid clarity for a cold path no profile justifies. Same line, opposite calls — because the audience is different. Name it first.
The one-question litmus, in order:
- Who calls this code? Unknown external/internal callers → you are a library author (§2); your own profilable service → app dev (§3). Set by who calls, not by binary-vs-module.
- Can I profile the real workload? No → don't pessimize the common case, offer the zero-alloc form. Yes → profile, then optimize only the proven hot path.
- Will a human debug this in prod? Always — so add the diagnosability (§4) that costs no clarity, regardless of the answers above.
- What does the next reader pay? If the speed gain is unmeasured or cold, the answer is "everything, for nothing" — don't (§5).
8. Don't
- Don't optimize before naming the audience. Library, app, SRE, and reader pull in different directions; "faster" without "for whom" is undefined.
- Don't (as a library author) force an allocation the caller can't opt out of. Offer the
Append/[]byte form; keep the zero value useful (go-perfbook; strconv).
- Don't expose
unsafe in a public API. Keep it behind a safe, copying-by-contract signature — the caller can't audit your invariants.
- Don't over-fit a library to one caller's microbenchmark. It taxes the whole distribution; "use this other package over here" is a valid answer (go-perfbook).
- Don't (as an app dev) micro-tune an unmeasured or cold path. Amdahl: 2× on 5% of runtime = 2.5% total (go-perfbook). Profile first →
go-perf-methodology.
- Don't ship code an SRE can't diagnose. Add
pprof labels, expose runtime/metrics, set GOMEMLIMIT, keep frames named — these cost no clarity.
- Don't make the next reader pay for a clever trick that saved 3ns on a cold path. "Clear is better than clever" (Proverbs).
- Don't apply the app-dev rule to an internal package with many unprofilable callers. A shared
internal/ helper is a library to its callers; treat its API by §2's distribution rules.
- Don't treat "~" from benchstat as a reason to ship anyway. If the win isn't measurable, the clarity you spent bought nothing for any audience — revert it (→
go-perf-methodology).
9. Routing to Related Skills
The policy root and the method (read these first):
go-idiomatic-discipline — the dual-axis "clear AND correct" policy; the next-reader tax (§5) and the clarity bound (§6) are its axis-2.
go-perf-methodology — measure-first, "no profile, no trade," USE, benchstat, Amdahl; the app developer's (§3) entire toolkit.
Library API allocation patterns (§2):
go-perf-strings-bytes-zerocopy — strconv.Append*, []byte↔string no-copy, strings.Builder, unsafe.String safety rules.
go-perf-slices — make([]T, 0, n), b = b[:0] reuse, append growth — the buffer the caller owns.
Diagnosability for the SRE (§4):
go-perf-pprof-profiling — pprof.Labels/pprof.Do to slice a profile by request type; production net/http/pprof.
go-perf-godebug-and-metrics — runtime/metrics, gctrace, exposing metrics via expvar/Prometheus.
go-perf-gc-tuning — GOMEMLIMIT so the pod GCs instead of OOMing; allocation rate as the lever.
go-perf-tail-latency — p99/p999 the on-call actually watches.
The unsafe risk (§2, §5):
go-perf-strings-bytes-zerocopy — unsafe.String/unsafe.Slice invariants; why they stay out of the API.
go-perf-simd — hand assembly / unsafe bypasses bounds checks and the race detector; the reader's audit cost.
10. Reference Files
Audience anti-patterns — the same change right for one audience and wrong for another, each a wrong/right contrast with citations:
${CLAUDE_SKILL_DIR}/references/common-mistakes.md
Source provenance for every claim in this skill:
${CLAUDE_SKILL_DIR}/references/sources.yaml