Profiling and Benchmarking with pprof
Reviewed & published by Brayan K
Stop guessing where your program spends its time. You'll write benchmarks with testing.B, measure allocations with -benchmem, capture CPU, heap, and goroutine profiles with runtime/pprof and net/http/pprof, and read them with go tool pprof — plus a peek at runtime/trace.
Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Write a benchmark with func BenchmarkX(b *testing.B)
- Loop over b.N and reset timing with b.ResetTimer
- Run go test -bench=. -benchmem for ns/op and allocs/op
- Capture profiles with runtime/pprof and net/http/pprof
- Read profiles with go tool pprof (top, list, web)
- Trace scheduling and GC with runtime/trace
1️⃣ Writing a benchmark
A benchmark is a function named BenchmarkX that takes *testing.B and loops b.N times. The framework chooses b.N to get a stable measurement. Add -benchmem to also see allocations per operation.
package main
import (
"strings"
"testing"
)
// joinLoop builds a string by repeated concatenation (slow: O(n^2) copies).
func joinLoop(parts []string) string {
s := ""
for _, p := range parts {
s += p
}
return s
}
// joinBuilder uses strings.Builder (fast: one growing buffer).
func joinBuilder(parts []string) string {
var b strings.Builder
for _, p := range parts {
b.WriteString(p)
}
return b.String()
}
var parts = strings.Split(strings.Repeat("go ", 500), " ")
// A benchmark is named BenchmarkX and takes *testing.B.
func BenchmarkJoinLoop(b *testing.B) {
b.ResetTimer() // discard the setup above from the timing
for i := 0; i < b.N; i++ {
_ = joinLoop(parts) // the framework picks b.N for a stable measurement
}
}
func BenchmarkJoinBuilder(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = joinBuilder(parts)
}
}2️⃣ Capturing and reading a CPU profile
Add -cpuprofile=cpu.out to your benchmark run, then open it with go tool pprof. The most useful commands are top (hottest functions), list <func> (per-line cost), and web (a call graph).
# 1. Run the benchmarks and write a CPU profile to cpu.out
$ go test -bench=. -benchmem -cpuprofile=cpu.out
# 2. Open the profile interactively
$ go tool pprof cpu.out
# 3. Inside pprof, find the hottest functions:
(pprof) top
Showing nodes accounting for 1.20s, 92% of 1.30s total
flat flat% sum% cum cum%
0.80s 61.5% 61.5% 0.90s 69.2% main.joinLoop
0.20s 15.4% 76.9% 0.20s 15.4% runtime.memmove
# 4. Annotate the source line by line:
(pprof) list joinLoop
# 5. Render a graph in the browser (needs graphviz):
(pprof) web3️⃣ Profiling from your own code with runtime/pprof
Outside of tests, use runtime/pprof directly: StartCPUProfile/StopCPUProfile for CPU and WriteHeapProfile for a heap snapshot. Both write to a file you open with go tool pprof.
package main
import (
"os"
"runtime/pprof"
)
func main() {
// runtime/pprof: write a CPU profile directly, no test harness needed.
f, _ := os.Create("cpu.out")
defer f.Close()
pprof.StartCPUProfile(f) // begin sampling
defer pprof.StopCPUProfile()
work() // ... your program runs here ...
// For a heap snapshot at any point:
hf, _ := os.Create("heap.out")
defer hf.Close()
pprof.WriteHeapProfile(hf)
}
func work() {
sum := 0
for i := 0; i < 1_000_000; i++ {
sum += i
}
_ = sum
}4️⃣ Live profiling with net/http/pprof
For a running server, a blank import of net/http/pprof registers handlers under /debug/pprof/. You can then pull a CPU, heap, or goroutine profile from a live process without restarting it.
package main
import (
"net/http"
_ "net/http/pprof" // blank import registers /debug/pprof/ handlers
)
func main() {
// Just importing net/http/pprof wires profiling endpoints onto the
// DefaultServeMux. Run a server and visit them live in production.
http.ListenAndServe("localhost:6060", nil)
}
// Then, while it runs, grab a 30s CPU profile from another terminal:
// go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
// Or a heap / goroutine snapshot:
// go tool pprof http://localhost:6060/debug/pprof/heap
// go tool pprof http://localhost:6060/debug/pprof/goroutine🎯 Your Turn
Complete the benchmark loop. Fill in the blank marked ___ so it iterates the right number of times.
package main
import "testing"
func sum(n int) int {
total := 0
for i := 1; i <= n; i++ {
total += i
}
return total
}
// 🎯 YOUR TURN — fill in the blanks marked ___
func BenchmarkSum(b *testing.B) {
// 1) Loop exactly the number of times the framework asks for
for i := 0; i < b.___; i++ { // 👉 N
_ = sum(1000)
}
}
// ✅ Run it with:
// go test -bench=. -benchmem
// The output shows ns/op (and B/op with -benchmem).Common Errors (and the fix)
❌ Hard-coding the loop count instead of using b.N — the result is meaningless.
✅ Always for i := 0; i < b.N; i++.
❌ Counting setup in the timing — slow input prep inflates ns/op.
✅ Call b.ResetTimer() just before the loop.
❌ The compiler optimizes the work away because the result is unused.
✅ Assign to a package-level sink or _ = the result so it can't be eliminated.
❌ Forgetting -benchmem — you miss the allocation story behind a slow path.
✅ Run go test -bench=. -benchmem for allocs/op and B/op.
🧠 Quick Recall
The testing framework. It raises b.N until the run is long enough for a stable ns/op; you never set it yourself.
pprof command for hottest functions = ?
top. Then list <func> for per-line cost and web for a call graph.
🎯 Mini-Challenge: Append vs Prealloc
Benchmark appending 10,000 ints to a nil slice versus preallocating with make([]int, 0, 10000), and compare allocs/op under -benchmem.
🎉 Lesson Complete!
- ✅ Benchmarks are func BenchmarkX(b *testing.B) looping over b.N
- ✅ b.ResetTimer() excludes setup; -benchmem shows allocs/op
- ✅ -cpuprofile=cpu.out then go tool pprof (top, list, web)
- ✅ runtime/pprof writes profiles from code; net/http/pprof exposes them live
- ✅ runtime/trace records a scheduling/GC timeline for go tool trace
Practice quiz
What is the correct signature for a Go benchmark function?
- func BenchmarkX(b *testing.B)
- func BenchmarkX(t *testing.T)
- func Benchmark(x int) int
- func BenchX(b testing.B)
Answer: func BenchmarkX(b *testing.B). Benchmarks are named BenchmarkX and take a single *testing.B parameter; the testing tool runs them with go test -bench.
What does b.N represent inside a benchmark loop?
- the number of CPUs
- the number of nanoseconds elapsed
- the iteration count the framework chooses to get a stable timing
- a fixed value of 1000
Answer: the iteration count the framework chooses to get a stable timing. The testing package adjusts b.N upward until the benchmark runs long enough for a reliable measurement; you loop from 0 to b.N.
Why call b.ResetTimer()?
- to stop the benchmark early
- to discard expensive setup time before the measured loop
- to reset b.N to zero
- to enable the race detector
Answer: to discard expensive setup time before the measured loop. ResetTimer zeroes the elapsed time and memory counters so costly setup before the loop is not counted in the result.
Which flag adds memory allocation stats to benchmark output?
- -cover
- -v
- -race
- -benchmem
Answer: -benchmem. go test -bench=. -benchmem reports allocs/op and B/op alongside ns/op.
Which package writes profiles programmatically without an HTTP server?
- runtime/pprof
- net/http/pprof
- fmt
- encoding/json
Answer: runtime/pprof. runtime/pprof lets you call pprof.StartCPUProfile or WriteHeapProfile directly to a file from your code.
What does importing net/http/pprof do?
- disables the garbage collector
- registers profiling handlers under /debug/pprof/ on the default mux
- starts a CPU profile automatically
- nothing useful
Answer: registers profiling handlers under /debug/pprof/ on the default mux. The blank import _ "net/http/pprof" registers /debug/pprof/ endpoints on http.DefaultServeMux for live profiling.
Which go test flag writes a CPU profile to a file?
- -trace
- -blockprofile
- -memprofile
- -cpuprofile
Answer: -cpuprofile. go test -bench=. -cpuprofile=cpu.out writes a CPU profile you can open with go tool pprof.
Which go tool pprof command lists the hottest functions?
- save
- build
- top
- edit
Answer: top. Inside go tool pprof, top shows the functions consuming the most time or memory; list <func> shows annotated source.
Which profile shows where goroutines are currently blocked or stacked?
- the CPU profile
- the goroutine profile
- the mutex profile only
- the heap profile
Answer: the goroutine profile. The goroutine profile dumps the stack of every running goroutine, which is ideal for diagnosing leaks and deadlocks.
What does the runtime/trace package capture that pprof does not?
- a timeline of scheduling, GC, and goroutine events
- the source code of functions
- the values of variables
- only allocation counts
Answer: a timeline of scheduling, GC, and goroutine events. runtime/trace records a detailed execution timeline (scheduler, GC, syscalls, goroutines) viewed with go tool trace.
Continue this course
- Previous: Building CLIs with Cobra
- Next: The Race Detector & Memory Model — Catch data races with go test -race and understand happens-before
Frequently asked questions
What does b.N mean and why don't I set it myself?
b.N is the iteration count the testing framework chooses for you. It runs your benchmark a few times, increasing b.N until the total runtime is long enough to give a statistically stable number, then reports ns/op (nanoseconds per operation). You simply write for i := 0; i < b.N; i++ around the code you want to measure — never hard-code the count.
When should I call b.ResetTimer()?
Call b.ResetTimer() right before the measured loop whenever there is expensive setup (building input data, opening a file, warming a cache) that you do not want counted. It zeroes the elapsed-time and allocation counters so the reported ns/op and allocs/op reflect only the loop body. There is also b.StopTimer/b.StartTimer to exclude work inside the loop.
What is the difference between runtime/pprof and net/http/pprof?
runtime/pprof is the low-level package: you call pprof.StartCPUProfile(f) / StopCPUProfile() or pprof.WriteHeapProfile(f) to write a profile to a file from your own code. net/http/pprof is a convenience wrapper — a blank import registers HTTP handlers under /debug/pprof/ on the default mux so you can pull CPU, heap, goroutine, and block profiles from a live, running server.
Which profile type should I use for which problem?
Use the CPU profile to find where time is spent (hot functions). Use the heap profile to find what is allocating or holding memory. Use the goroutine profile to diagnose leaks and deadlocks by seeing every goroutine's stack. Use the block and mutex profiles to find contention on channels and sync primitives. Each is just a different sample set that go tool pprof reads.
How do I read a profile with go tool pprof?
Run go tool pprof <profile> (a file like cpu.out, or a live URL such as http://localhost:6060/debug/pprof/profile). At the interactive prompt, top ranks the hottest functions, list <func> shows per-line cost in annotated source, and web opens a visual call graph in your browser (it needs graphviz installed). You can also pass -http=:8080 to get the web UI directly.
How is runtime/trace different from pprof?
pprof gives you statistical samples — where time or memory goes on average. runtime/trace records an exact timeline of runtime events: goroutine scheduling, garbage-collection pauses, syscalls, and channel operations. You capture it with trace.Start(f)/trace.Stop() (or go test -trace) and view it with go tool trace, which is invaluable for understanding latency, GC pauses, and why goroutines stall.