Profiling Go with pprof
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
“This endpoint feels slow” is not a finding. Performance work starts with a repeatable measurement and ends with the three lines of code that actually consume the CPU. Go ships the profiler in the toolchain; nothing needs to be added to go.mod.
Before you start
You need Go 1.24 and go tool pprof, which are the same install. benchstat compares runs and is a separate binary:
go install golang.org/x/perf/cmd/benchstat@latest
Make sure $(go env GOPATH)/bin is on PATH, otherwise the shell cannot find benchstat.
Start with a benchmark you can rerun:
package parse
import (
"strings"
"testing"
)
func Parse(line string) (string, string) {
key, value, ok := strings.Cut(line, "=")
if !ok {
return "", ""
}
return strings.TrimSpace(key), strings.TrimSpace(value)
}
func BenchmarkParse(b *testing.B) {
line := " user_id = 42 "
b.ReportAllocs()
for b.Loop() {
Parse(line)
}
}
b.Loop() (new in Go 1.24) keeps the call from being optimised away and handles timer start and stop for you. The older for i := 0; i < b.N; i++ form still works but needs a package-level sink to survive inlining.
go test -bench BenchmarkParse -benchmem ./parse
-benchmem adds B/op and allocs/op. Allocation problems usually show up there before they show up in a profile.
Capturing a profile
Benchmarks can emit profiles without touching application code:
go test -bench BenchmarkParse -benchmem \
-cpuprofile cpu.out -memprofile mem.out ./parse
Two files come out: cpu.out is sampled CPU time, mem.out is a sampled heap. A few rules:
- The default sampling rate is enough to find hotspots. Do not set
-memprofilerate=1“for accuracy”; it slows the benchmark by an order of magnitude. - A heap profile is a snapshot of the heap at that moment. Call
runtime.GC()before capturing if you care about stableinuse_spacenumbers. - One run represents one run. Make sure
-benchtimeis long enough; the 1s default usually is.
Reading pprof
go tool pprof cpu.out
Four commands cover most investigations:
(pprof) top
(pprof) top -cum
(pprof) list Parse
(pprof) web
topsorts by flat time (the function itself, excluding callees) and points straight at the hot code.top -cumsorts by cumulative time and shows which call path is expensive overall.list Parseprints per-line cost, as long as the binary still has symbols.webrenders a call graph and needs Graphviz’sdot.
Without Graphviz, use the built-in web UI:
go tool pprof -http=:8080 cpu.out
It serves Graph, Flame Graph and Source views on localhost:8080. In a flame graph the horizontal axis is merged stack width — wider means more samples — and the vertical axis is call depth. Look for frames that are wide and flat: those are functions spending time in themselves rather than in callees.
The common misread: a wide frame near the top is not automatically slow, it may simply be called very often. Cross-check with the flat ordering from top.
Memory and allocations
go tool pprof -sample_index=alloc_space mem.out
The default view is inuse_space (bytes held by live objects). The other indexes answer different questions:
-sample_index |
Meaning |
|---|---|
inuse_space |
bytes held by live objects at capture time |
inuse_objects |
number of live objects |
alloc_space |
bytes allocated cumulatively, including freed |
alloc_objects |
objects allocated cumulatively |
A heap that keeps growing calls for inuse_space. High GC CPU with a flat heap calls for alloc_space and allocs/op.
To compare two captures, diff them instead of eyeballing two top outputs:
go tool pprof -diff_base before.out after.out
Positive values mean the change added cost, negative means it removed cost.
Profiling a live server
net/http/pprof registers handlers on http.DefaultServeMux through a blank import:
package main
import (
"log"
"net/http"
_ "net/http/pprof"
)
func main() {
go func() {
// Loopback only. Never 0.0.0.0.
log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
}()
// Application server
_ = http.ListenAndServe(":8080", nil)
}
The endpoints worth remembering:
curl 'http://127.0.0.1:6060/debug/pprof/profile?seconds=30' -o cpu.out
curl 'http://127.0.0.1:6060/debug/pprof/heap' -o heap.out
curl 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=1'
seconds=30 is the CPU sampling window and does add overhead, so keep it short during peak traffic. goroutine?debug=1 dumps every stack and finds goroutine leaks faster than any profile.
Safety rules:
- Bind to
127.0.0.1and reach it through an SSH tunnel or a private network. Never mount it on the public mux. - If it must listen on a separate port, restrict it with a firewall allowlist.
- Binaries built with
-trimpath -s -wmay not resolve source lines inlist; keep a symbol-rich build for debugging.
Block and mutex profiles are off by default and must be enabled in code:
runtime.SetBlockProfileRate(1)
runtime.SetMutexProfileFraction(1)
A rate of 1 records every event and is expensive. Turn it on while investigating, then turn it off. The endpoints are /debug/pprof/block and /debug/pprof/mutex.
Confirming a change with benchstat
A single benchmark run is mostly noise. Ten runs plus a comparison is the minimum:
go test -run '^$' -bench . -count=10 -benchmem ./parse > old.txt
# change the code
go test -run '^$' -bench . -count=10 -benchmem ./parse > new.txt
benchstat old.txt new.txt
-run '^$' skips ordinary tests and runs benchmarks only. benchstat reports medians, percentage change and a geomean, and marks whether the difference is statistically meaningful.
On Windows, watch the redirection encoding: PowerShell 5 writes UTF-16 by default, which benchstat cannot parse. Use cmd /c "go test ... > old.txt" or | Out-File -Encoding utf8 old.txt.
Decide the bar before measuring. A 20% improvement is worth merging; a 2% “win” inside the noise band is not.
Common mistakes
1. Benchmark results that are never used
The compiler removes calls without side effects and the benchmark measures nothing. Use b.Loop(), or assign to a package-level var Sink any.
2. Concluding from one run
CPU frequency, scheduling and neighbouring processes all move the number. -count=10 plus benchstat is the floor.
3. A -benchtime that is too short
Too few samples, and the profile is mostly noise. Start at the default 1s and raise it if the hotspot stays unclear.
4. Leaving CPU profiling on in production
Sampling costs CPU. Thirty seconds is a common ceiling; avoid peak hours.
5. Exposing /debug/pprof to the internet
It is a remote diagnostic backdoor that can reveal heap contents and call stacks. Loopback only.
6. Watching CPU and ignoring allocations
GC pressure is often the real latency source. Read -benchmem, alloc_space and GOGC together.
7. Comparing absolute numbers on shared CI runners
CPU quotas move between runs. Compare before/after on the same machine, or track trends only.
8. Optimising without a measurement
Intuition usually moves the cost somewhere else. Profile, change, then verify with benchstat.
Summary
- Get a repeatable benchmark first (
b.Loop()plus-benchmem), then optimise. -cpuprofileand-memprofileproduce profiles from tests; live servers usenet/http/pprofbound to loopback.topfinds flat hotspots,top -cumshows call paths,listmaps cost to lines, the flame graph shows shape.- Use
-sample_indexfor memory:inuse_spacefor leaks,alloc_spacefor GC pressure. - Let
benchstatdecide whether a change worked, with-count=10as the starting point.
Next: profile the HTTP service from the practice chapter under real load, then diff the heap before and after the fix.