# Go Performance

> Guides Go performance work as a measure-first discipline — never optimize from intuition; profile or benchmark first, then optimize only what the data proves is hot, without sacrificing clarity for unmeasured speed. Covers benchmarking with go test -bench -benchmem and comparing runs with benchstat (not eyeballing one noisy run); profiling with pprof (CPU -cpuprofile, heap -memprofile, net/http/pprof for live servers; top/list/web); escape analysis via go build -gcflags=-m (stack vs heap). Auto-invokes when optimizing Go performance, reducing allocations, profiling with pprof, reading escape analysis, or enabling PGO, and on "make this faster" / "why is this allocating" / "profile this" requests. Routes benchmark mechanics to go-testing-advanced, sync.Pool to go-sync-primitives.

- Skill: `ctoth/go-performance` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-performance`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-performance/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-performance

---


# Go Performance

> "You can't tell where a program is going to spend its time. Bottlenecks occur in surprising places, so don't try to second guess and put in a speed hack until you've proven that's where the bottleneck is."
> "Measure. Don't tune for speed until you've measured, and even then don't unless one part of the code overwhelms the rest."
> — Rob Pike, [Notes on Programming in C](https://www.lysator.liu.se/c/pikestyle.html)

> "We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil."
> — Donald Knuth, *Structured Programming with go to Statements* (Computing Surveys, 1974)

Performance work in Go is a **measure-first discipline**, not a bag of tricks. The single most expensive mistake is optimizing code that was never hot — paying readability to speed up a path the program barely runs. This skill makes the loop explicit: **profile or benchmark, find the real hot path, change one thing, measure again.** Everything below serves that loop.

All Go below builds clean under `gofmt`, `go vet`, and `go test` on Go 1.26.

---

## 1. The Discipline: Measure First, Never Guess

The order is non-negotiable, because "bottlenecks occur in surprising places" ([Pike](https://www.lysator.liu.se/c/pikestyle.html)):

1. **Profile or benchmark** to find where the time and allocations actually go.
2. **Change one thing** that the data points at.
3. **Measure again** — and compare statistically, not by eyeball.

This is the same dual-axis judgment as the policy root (`go-idiomatic-discipline`): "clear AND correct." An unmeasured micro-optimization that trades clarity for speed you never proved is the *ceiling* failure — it ships, it's correct, and the next reader pays for cleverness that bought nothing. The bar for sacrificing readability is a profile or a benchmark that names the cost. No profile, no trade. "Clear is better than clever" ([Go Proverbs](https://go-proverbs.github.io/)) loses only to evidence.

| Tool | Question it answers | Command |
|---|---|---|
| **Benchmark** (`-benchmem`) | How fast / how many allocs is this function? | `go test -bench=. -benchmem` |
| **benchstat** | Is the change real or noise? | `benchstat old.txt new.txt` |
| **CPU profile** | Where does wall/CPU time go? | `go test -cpuprofile=cpu.prof` |
| **Heap profile** | Where do allocations come from? | `go test -memprofile=mem.prof` |
| **Escape analysis** | What is forced onto the heap? | `go build -gcflags=-m` |
| **PGO** | Free inlining of *measured* hot funcs | `default.pgo` in the main package |

---

## 2. Benchmark with `-benchmem`, Compare with benchstat

A benchmark is the unit of evidence. `go test -bench=. -benchmem` adds the allocation columns; `b.ReportAllocs()` turns them on per-function. The **benchmark mechanics** — `for b.Loop()` (Go 1.24), the dead-code/sink trap, `b.ResetTimer`, `b.RunParallel` — are owned by **`go-testing-advanced` §2–3**; read it for how to write the loop. This skill owns what you do with the numbers.

```
BenchmarkJoinNaive-32      6748058    179.3 ns/op   216 B/op   7 allocs/op
BenchmarkJoinBuilder-32   24888777     49.11 ns/op   48 B/op   1 allocs/op
```

The `allocs/op` column is the lever: 7 → 1 allocations is a real, durable win (string `+=` in a loop reallocates every iteration; `strings.Builder` with one `Grow` does not). But **one run is not a measurement** — CPU scaling, GC, and noise swamp small deltas. To know a change is real, run each side ≥10 times and let `benchstat` do the statistics:

```sh
go test -run='^$' -bench=. -count=10 > old.txt
# ... make the change ...
go test -run='^$' -bench=. -count=10 > new.txt
benchstat old.txt new.txt
```

"Benchstat computes statistical summaries and A/B comparisons of Go benchmarks" and "Each benchmark should be run at least 10 times to gather a statistically significant sample" ([benchstat](https://pkg.go.dev/golang.org/x/perf/cmd/benchstat)). It reports the delta with a p-value: a `~` "indicates no statistically significant difference," while `-17.20% (p=0.000 n=10)` is a change you can trust ([benchstat](https://pkg.go.dev/golang.org/x/perf/cmd/benchstat)). Reporting a single before/after pair as proof is the classic eyeball error.

---

## 3. Profiling with pprof — Find the Real Hot Path

When you don't already know which function is hot, profile; don't guess. "Profiling tools analyze the complexity and costs of a Go program such as its memory usage and frequently called functions to identify the expensive sections" ([Diagnostics](https://go.dev/doc/diagnostics)). The two profiles you reach for most:

- **CPU** — "determines where a program spends its time while actively consuming CPU cycles (as opposed to while sleeping or waiting for I/O)" ([Diagnostics](https://go.dev/doc/diagnostics)). When enabled, "the Go program stops about 100 times per second and records a sample consisting of the program counters on the currently executing goroutine's stack" ([Profiling Go Programs](https://go.dev/blog/pprof)).
- **heap** — "reports memory allocation samples; used to monitor current and historical memory usage, and to check for memory leaks" ([Diagnostics](https://go.dev/doc/diagnostics)).

The fastest way to get both is straight from a benchmark — no code changes:

```sh
go test -bench=. -cpuprofile=cpu.prof -memprofile=mem.prof
go tool pprof -top cpu.prof      # the heaviest functions
go tool pprof cpu.prof           # interactive: top, list Fn, web
```

Inside `go tool pprof`, three commands carry the work: "The most important command is `topN`, which shows the top `N` samples"; `list Fn` shows per-line counts where "the first three columns are the number of samples taken while running that line, the number … in code called from that line, and the line number"; and "the `web` command writes a graph of the profile data in SVG format and opens it in a web browser" (needs graphviz) ([Profiling Go Programs](https://go.dev/blog/pprof)). Use `-cum` to sort by cumulative cost up the call tree.

**Live servers:** import `net/http/pprof` for its side effect — "Package pprof serves via its HTTP server runtime profiling data" under paths that "all begin with `/debug/pprof/`" ([net/http/pprof](https://pkg.go.dev/net/http/pprof)):

```go
import _ "net/http/pprof"  // registers /debug/pprof/ handlers on the default mux
// go func() { log.Println(http.ListenAndServe("localhost:6060", nil)) }()
```

```sh
go tool pprof http://localhost:6060/debug/pprof/heap
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30   # 30s CPU
```

It is "safe to profile programs in production, but enabling some profiles (e.g. the CPU profile) adds cost" ([Diagnostics](https://go.dev/doc/diagnostics)). One caveat: "Use tools in isolation to get more precise info" — precise memory profiling skews CPU profiles ([Diagnostics](https://go.dev/doc/diagnostics)). `block` and `mutex` profiles are off by default (`runtime.SetBlockProfileRate`, `runtime.SetMutexProfileFraction`).

---

## 4. Escape Analysis — Stack Is Free, Heap Costs GC

Stack allocations are reclaimed for free when the function returns; heap allocations create GC work. The compiler decides per value, and it will **show you its reasoning**: `-m` is documented as "Print optimization decisions" ([cmd/compile](https://pkg.go.dev/cmd/compile)).

```sh
go build -gcflags=-m ./...     # add a second -m (-gcflags='-m -m') for more detail
```

The lines that matter read like `moved to heap: x`, `... escapes to heap`, or the reassuring `... does not escape`. A verified example — returning the address of a local forces it onto the heap:

```go
// escapes: the local p outlives the call, so it must live on the heap
func NewPointHeap(x, y int) *Point { p := Point{x, y}; return &p }
//   ./perf.go:51:2: moved to heap: p

// stays on the stack: the value is copied in, nothing outlives the call
func SumPointValue(p Point) int { return p.X + p.Y }
```

The recurring escape triggers to recognize in a hot path:

- **Returning a pointer to a local** (above) — return the value if it's small; the copy is cheaper than the GC.
- **Interface boxing** — assigning a concrete value to an `any`/`interface{}` (and `fmt.Sprint(x)`, whose `...any` boxes every argument) heap-allocates it.
- **Closures capturing by reference** — a variable captured by a closure that outlives the frame escapes.
- **Slices/maps that outlive the call** or grow past a size the compiler can't bound.

Escape analysis tells you *why* the heap profile looks the way it does — read them together.

---

## 5. Reduce Allocations Once a Profile Points There

Only after a heap profile or `-benchmem` names the cost. The high-leverage moves, each verified by an alloc delta:

- **Preallocate when the size is known.** `make([]T, 0, n)` gives `append` its backing array once instead of growing-and-copying. Measured: appending 1024 ints with no prealloc was **25208 B/op, 12 allocs/op**; with `make([]int, 0, n)` it was **0 B/op, 0 allocs/op**. Same for maps: `make(map[K]V, n)`. (Slice/map mechanics: `go-slices-and-maps`.)
- **Build strings with `strings.Builder`, not `+=`.** `s += p` in a loop reallocates the whole string every iteration. Measured: naive `+=` was **216 B/op, 7 allocs/op**; `strings.Builder` with one `Grow(n)` was **48 B/op, 1 allocs/op**. (Builder + conversion cost: `go-strings-bytes-runes`.)
- **Avoid `[]byte`↔`string` conversions in hot loops** — each conversion copies. Work in one representation; reach for `strconv` over `fmt` for number formatting.
- **Reuse buffers** — `buf = buf[:0]` keeps the capacity and resets the length.
- **`sync.Pool` for transient objects.** "A Pool is a set of temporary objects that may be individually saved and retrieved"; its "purpose is to cache allocated but unused items for later reuse, relieving pressure on the garbage collector" ([sync.Pool](https://pkg.go.dev/sync#Pool)). The caveat: "Any item stored in the Pool may be removed automatically at any time without notification" — so it's for *fungible* scratch (byte buffers), never for objects that must persist. Mechanics and copylock rules: **`go-sync-primitives`**.

---

## 6. PGO — Free Inlining of Functions a Profile Proves Hot

Profile-Guided Optimization feeds a real CPU profile back into the compiler. "The Go toolchain will automatically enable PGO when it finds a profile named `default.pgo` in the main package directory" ([PGO blog](https://go.dev/blog/pgo)); "By default, `go build` will detect `default.pgo` files automatically and enable PGO" ([PGO guide](https://go.dev/doc/pgo)). Profiles "from `runtime/pprof` and `net/http/pprof` can be used directly as the compiler input" — so the CPU profile you already collected *is* the input.

```sh
go tool pprof -proto ... > default.pgo   # a representative production CPU profile
go build ./...                           # auto-detected; no flag needed
```

With it, "the compiler may decide to more aggressively inline functions which the profile indicates are called frequently" ([PGO guide](https://go.dev/doc/pgo)). The payoff is modest and free: "benchmarks for a representative set of Go programs show that building with PGO improves performance by around 2-14%" ([PGO guide](https://go.dev/doc/pgo)). **Commit `default.pgo`** to the repo — it "ensures that users automatically have access to the profile … and that builds remain reproducible" ([PGO blog](https://go.dev/blog/pgo)). Collect it from production, not a synthetic micro-benchmark, and refresh it occasionally as the code drifts.

---

## 7. Runtime Wins You Get for Free

Two recent releases cut overhead with no code change — they raise the floor before you optimize anything:

- **Container-aware `GOMAXPROCS` (Go 1.25).** "On Linux, the runtime considers the CPU bandwidth limit of the cgroup containing the process … If the CPU bandwidth limit is lower than the number of logical CPUs available, `GOMAXPROCS` will default to the lower limit" ([Go 1.25](https://go.dev/doc/go1.25)). This stops a containerized binary from spawning a thread per host core and thrashing under a Kubernetes CPU limit. It "periodically updates `GOMAXPROCS` if … the cgroup CPU bandwidth limit change[s]," and is disabled if you set `GOMAXPROCS` manually.
- **Green Tea GC default (Go 1.26).** The collector that "improves the performance of marking and scanning small objects through better locality and CPU scalability," previously a 1.25 experiment, "is now enabled by default" — "we expect somewhere between a 10–40% reduction in garbage collection overhead in real-world programs that heavily use the garbage collector" ([Go 1.26](https://go.dev/doc/go1.26)). Opt out with `GOEXPERIMENT=nogreenteagc` (expected removed in 1.27).

Gate any version-specific idiom on the module's `go` directive — see `go-version-feature-map`.

---

## 8. Don't

- **Don't optimize without a profile.** Guessing the hot spot is the root error; "bottlenecks occur in surprising places" ([Pike](https://www.lysator.liu.se/c/pikestyle.html)).
- **Don't micro-optimize cold code.** A 50% speedup on a path that runs 0.1% of the time is noise you paid clarity for.
- **Don't trade readability for unproven speed.** That's the axis-2 failure of `go-idiomatic-discipline`. The price of clever is a profile that names the win.
- **Don't eyeball one benchmark run.** Use `-count` and `benchstat`; a single delta is within the noise.
- **Don't tune the GC (`GOGC`, `GOMEMLIMIT`) before measuring** that GC is your bottleneck — a mutex or a syscall usually isn't fixed by GC knobs.
- **Don't `sync.Pool` objects that must persist** — pooled items can vanish at any time.

---

## 9. Routing to Related Skills

- `go-idiomatic-discipline` — the policy root; unmeasured micro-opt vs clarity is its axis-2 ("clear AND correct") failure.
- `go-testing-advanced` — benchmark **mechanics**: `for b.Loop()`, the sink/dead-code trap, `b.ReportAllocs`, `b.RunParallel`. This skill interprets the numbers it produces.
- `go-sync-primitives` — `sync.Pool` mechanics, copylock, and the rest of shared-memory synchronization.
- `go-slices-and-maps` — preallocation (`make([]T,0,n)`), capacity, append/aliasing semantics.
- `go-strings-bytes-runes` — `strings.Builder` and the cost of `[]byte`↔`string` conversion.
- `go-version-feature-map` — version floors: PGO GA 1.21, container-aware `GOMAXPROCS` 1.25, Green Tea GC default 1.26.

---

## 10. Reference Files

High-frequency performance anti-patterns in LLM-generated Go, each with wrong/right code and citations:

[references/common-mistakes.md](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

