Back to blog
Aug 08, 2026
5 min read

Profiling Go Programs with pprof

A practical look at CPU, memory, and goroutine profiling in Go — how to collect profiles, read them, and avoid the usual mistakes when debugging performance.

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:

  1. What is holding memory right now?inuse_space
  2. 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 top without 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=:8080 or 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:

  1. Check basic metrics (CPU, RSS, GC frequency, goroutine count)
  2. Grab a goroutine dump if the process looks stuck
  3. Take a short CPU profile under load
  4. Take a heap profile (both inuse and alloc)
  5. 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.