When a Go service starts using more CPU or memory than you expect, guessing rarely helps. You need data. That’s what pprof is for.
I’ve lost count of how many times a profile immediately showed something obvious that I had been overthinking for hours. The tool is not perfect, but it’s one of the best things about the Go ecosystem.
The two ways you usually collect profiles
1. Import net/http/pprof and expose the endpoints
import _ "net/http/pprof"
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
This gives you:
/debug/pprof//debug/pprof/profile(CPU)/debug/pprof/heap/debug/pprof/goroutine/debug/pprof/block/debug/pprof/mutex
I only expose this on an internal port or behind authentication. Leaving it open on a public service is a bad idea.
2. Use the runtime/pprof package directly
Useful for one-off benchmarks or CLI tools:
f, _ := os.Create("cpu.prof")
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
Same idea for heap:
f, _ := os.Create("heap.prof")
defer pprof.WriteHeapProfile(f)
CPU profiling
CPU profiles sample the call stack at regular intervals (default is 100Hz). They answer the question: “Where is the program spending time?”
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
Inside the interactive tool:
(pprof) top
(pprof) top -cum
(pprof) list FunctionName
(pprof) web # needs graphviz
A few things that matter in practice:
- Look at both flat and cumulative time. Flat is time spent in the function itself; cumulative includes children.
- If you see a lot of time in
runtime.*, it can mean allocation pressure or scheduling issues more than “slow code”. - Short profiles can be noisy. 20–30 seconds under realistic load is usually more useful than 5 seconds.
Memory profiling
Heap profiles are sampled. By default Go records a fraction of allocations. That means the numbers are statistical, not exact byte counts.
go tool pprof http://localhost:6060/debug/pprof/heap
Useful views:
(pprof) top
(pprof) inuse_space # currently allocated
(pprof) alloc_space # total allocated over time
(pprof) list FunctionName
I care about two different questions:
- What is holding memory right now? →
inuse_space - What is allocating a lot over time (GC pressure)? →
alloc_space
These often point to different problems. A service can have high allocation rate and still low steady-state memory (or the opposite).
Goroutine profiles
When a service feels “stuck” or latency spikes for no obvious reason, I look at goroutines:
go tool pprof http://localhost:6060/debug/pprof/goroutine
Or just:
curl -s http://localhost:6060/debug/pprof/goroutine?debug=1 | less
This is often enough to spot:
- Goroutines blocked on a channel no one is reading
- A thundering herd waiting on a lock
- A leak (goroutines that keep growing over time)
If the number of goroutines only goes up and never down, you almost certainly have a leak.
Block and mutex profiles
These are off by default because they have some overhead.
runtime.SetBlockProfileRate(1) // 1 = every blocking event
runtime.SetMutexProfileFraction(1) // 1 = every mutex contention
Then:
go tool pprof http://localhost:6060/debug/pprof/block
go tool pprof http://localhost:6060/debug/pprof/mutex
I only turn these on when I’m actively investigating contention. Leaving them at high sampling rates in production is usually not worth it.
Reading profiles without losing your mind
A few habits that help:
- Always compare against a baseline. A profile in isolation is harder to interpret than “before vs after”.
- Prefer profiles taken under load that resembles production. Idle profiles are mostly noise.
- Don’t optimize the first function you see in
topwithout checking callers. Sometimes the expensive leaf is only expensive because it’s called in a tight loop from somewhere else. - Flame graphs (via
pprof -http=:8080or go-torch / speedscope) make the call hierarchy much clearer than text.
go tool pprof -http=:8080 cpu.prof
This opens a local web UI. Use it.
Common mistakes
Profiling the wrong thing
People take a CPU profile when the problem is GC pressure or lock contention. Match the tool to the symptom.
Optimizing before measuring
It’s still surprisingly common. Write the clear version first, measure, then decide if it matters.
Trusting alloc counts as absolute truth
Heap profiles are sampled. Treat them as directional, not as exact accounting.
Leaving pprof exposed
If you need it in production, protect it. At minimum bind to localhost or an internal interface, or put it behind auth.
A realistic workflow
When something is slow or fat in production, my usual sequence is:
- Check basic metrics (CPU, RSS, GC frequency, goroutine count)
- Grab a goroutine dump if the process looks stuck
- Take a short CPU profile under load
- Take a heap profile (both inuse and alloc)
- Only then look at block/mutex if the above points to contention
Most of the time the answer shows up in steps 2–4.
Closing
pprof won’t design a better algorithm for you, but it will stop you from guessing. Once you get comfortable reading profiles, a lot of “performance work” becomes much more mechanical — and a lot less stressful.
If you only learn one thing from this post, make it this: measure first, and measure under conditions that actually look like production.