- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Your Go service is burning CPU. The dashboard says one endpoint
went from 20ms to 300ms and the CPU graph is flat-topped at the
node limit. You have a guess about which function is at fault. You
are probably wrong, because everybody is wrong about where the time
goes until they measure it.
Go ships a profiler in the standard library. No agent, no external
service, no rebuild with special flags. pprof has been in the
runtime since Go 1, it works in production, and reading its output
is a skill you learn in an afternoon. This walkthrough goes from a
service with no profiling to a flame graph pointing at the exact
line allocating your garbage.
Wiring net/http/pprof in one import
The fastest way to get profiles out of a running service is the
net/http/pprof package. Importing it for its side effects
registers a handful of routes on the default http.ServeMux.
package main
import (
"net/http"
_ "net/http/pprof"
)
func main() {
// your real handlers on the default mux
http.HandleFunc("/work", workHandler)
http.ListenAndServe(":8080", nil)
}
That blank import is the whole setup. It adds /debug/pprof/ and
its children (/debug/pprof/profile, /heap, /goroutine, and
friends) to the default mux.
One caution: never expose /debug/pprof/ on a public interface.
The endpoints leak internals and let anyone trigger a CPU profile
on your box. In production, put them on a separate admin port bound
to localhost or an internal network.
func main() {
go func() {
// pprof only, internal listener
http.ListenAndServe("127.0.0.1:6060", nil)
}()
app := http.NewServeMux()
app.HandleFunc("/work", workHandler)
http.ListenAndServe(":8080", app)
}
Now the public mux (app) has no pprof routes, and the profiler
lives on 127.0.0.1:6060 where only an SSH tunnel or a sidecar can
reach it.
Capturing a CPU profile
A CPU profile samples the call stack about 100 times per second
while it runs. You tell it how long to sample with the seconds
query parameter. Thirty seconds under real traffic is a good
starting point.
go tool pprof http://127.0.0.1:6060/debug/pprof/profile?seconds=30
The command blocks for 30 seconds while the runtime collects
samples, downloads the profile, and drops you into an interactive
prompt:
Type: cpu
Duration: 30s, Total samples = 24.10s (80.34%)
Entering interactive mode (type "help" for commands)
(pprof)
Total samples = 24.10s means the profiler caught 24 seconds of
on-CPU time across all goroutines during the 30-second window. That
is your budget to explain.
If you want to profile a benchmark or a CLI instead of a server,
the runtime/pprof package writes a profile straight to a file, or
you pass -cpuprofile to go test:
go test -cpuprofile cpu.out -bench .
go tool pprof cpu.out
Reading it with top
The first command to run is always top. It ranks functions by how
much CPU they used.
(pprof) top
Showing nodes accounting for 17.85s, 74.07% of 24.10s total
Dropped 84 nodes (cum <= 0.12s)
flat flat% sum% cum cum%
8.30s 34.44% 34.44% 8.35s 34.65% bytes.Equal
4.10s 17.01% 51.45% 12.60s 52.28% main.dedupe
3.05s 12.66% 64.11% 3.05s 12.66% runtime.memmove
2.40s 9.96% 74.07% 2.40s 9.96% runtime.mallocgc
Two columns matter and people confuse them constantly:
- flat is time spent inside that function itself, not counting the functions it calls.
- cum (cumulative) is time in that function plus everything it called.
bytes.Equal has a high flat number, so the CPU is genuinely
sitting inside bytes.Equal. main.dedupe has a modest flat but a
large cum, which tells you dedupe is the caller driving most of
the cost. That combination points the finger: dedupe is calling
bytes.Equal in a hot loop.
Sort by cumulative when you want to find the expensive subtree
rather than the expensive leaf:
(pprof) top -cum
Zooming in with list
Once top names a suspect, list shows that function's source
annotated with per-line timings. Give it a function name or a
regex.
(pprof) list dedupe
Total: 24.10s
ROUTINE ======================== main.dedupe
4.10s 12.60s (flat, cum) 52.28% of Total
. . 40:func dedupe(rows [][]byte) [][]byte {
. . 41: var out [][]byte
1.20s 1.20s 42: for _, r := range rows {
. . 43: seen := false
2.90s 11.00s 44: for _, o := range out {
. 8.35s 45: if bytes.Equal(o, r) {
. . 46: seen = true
. . 47: break
. . 48: }
Line 44 and 45 carry the weight. The nested loop compares every row
against every kept row, so the work grows with the square of the
input. That is the classic O(n²) dedupe. The profile did not tell
you the algorithm was quadratic, but it pointed at the two lines
where the time lives, and the shape of the code told the rest.
The fix here is a map keyed by the byte content, turning the inner
scan into a hash lookup. But the point is the workflow: top finds
the function, list finds the line.
The web view and flame graphs
Text is fine for a single hot function. When the cost is spread
across a deep call tree, a picture reads faster. The web command
renders a call graph as SVG and opens it in your browser.
(pprof) web
Each box is a function. The edges show who called whom, and the box
size scales with cumulative time, so the expensive path is visibly
fatter. You need Graphviz installed for this (brew install or your package manager's equivalent).
graphviz
For a flame graph, run the profile through the web UI instead:
go tool pprof -http=:9000 cpu.out
That starts a local server and opens a browser with a menu. The
Flame Graph view stacks frames vertically: width is time, and a
wide bar low in the stack that stays wide as you go up is your hot
path. You read it left to right for "what ran," bottom to top for
"who called it." Clicking a frame zooms into that subtree. For most
people this is the fastest way to spot where a request spends its
time.
Heap profiling and the allocation hot path
CPU is only half the story. Allocations drive garbage collection,
and GC pressure shows up as CPU you did not write. The heap profile
tells you where memory comes from.
go tool pprof http://127.0.0.1:6060/debug/pprof/heap
The heap profile has four sample types, and picking the right one
matters:
-
inuse_space— bytes currently live (default). Use for leaks and steady-state footprint. -
inuse_objects— live object count. -
alloc_space— total bytes ever allocated. Use for GC pressure. -
alloc_objects— total objects ever allocated.
For a service that is fine on memory but spending too much time in
GC, you want alloc_space:
go tool pprof -sample_index=alloc_space \
http://127.0.0.1:6060/debug/pprof/heap
Then the same top and list you already know:
(pprof) top
flat flat% sum% cum cum%
1.85GB 41.20% 41.20% 1.85GB 41.20% main.render
0.90GB 20.04% 61.24% 2.75GB 61.24% main.handle
(pprof) list render
ROUTINE ======================== main.render
1.85GB 1.85GB (flat, cum)
. . 72:func render(items []Item) string {
. . 73: var s string
. . 74: for _, it := range items {
1.85GB 1.85GB 75: s += it.Name + ","
. . 76: }
. . 77: return s
Line 75 is the allocation hot path. String concatenation in a loop
builds a new backing array on every +=, so a slice of 10,000
items allocates thousands of intermediate strings. The heap profile
attributed 1.85GB of total allocation to that one line.
The idiomatic fix is strings.Builder, which grows one buffer
instead of reallocating on every step:
func render(items []Item) string {
var b strings.Builder
for _, it := range items {
b.WriteString(it.Name)
b.WriteByte(',')
}
return b.String()
}
Re-profile after the change. The same top should show render
fall off the list. That confirmation loop matters: a profile before
and a profile after is how you prove the fix did something instead
of hoping.
The loop to keep
Profiling is not a one-time ritual. The habit is: capture under
real load, run top to name the function, run list to name the
line, change one thing, re-profile to confirm. Guessing is what you
do before you have a profile. After it, you are reading a map.
Keep the endpoints behind an internal listener, keep Graphviz
installed on your laptop, and keep a benchmark next to the code you
care about so you can profile it without production traffic. The
tooling is already in your toolchain. The only thing between you and
the hot path is running go tool pprof.
Profiling rewards knowing what the runtime is actually doing under
your code — where allocations come from, how the scheduler spends
CPU, why the GC wakes up. The Complete Guide to Go Programming
digs into that runtime layer, from the memory model to escape
analysis, so a flame graph reads like a sentence instead of a
puzzle. Hexagonal Architecture in Go is the companion for keeping
this measurement at the right boundary, so your hot paths stay in
adapters you can profile and swap without touching the core.

Top comments (0)