“The API is slow” is the most common performance complaint and the least useful. Slow where? Slow in the network? In the database? In a loop that quadratic in the number of orders? Without a profiling tool, you’re guessing: moving things around, adding caches, rewriting code that might not be the bottleneck, and hoping you get lucky.
Go ships with a profiling tool built in: pprof. It’s in the standard
library, it works on any Go program, and it tells you exactly where your
program spends its resources. This post covers the three profiles that
matter most (CPU, memory, goroutine), how to collect them, and how to read
the output without a PhD in computer science.
The three profiles you’ll actually use
pprof can collect several types of profiles. Three of them cover 95% of
performance investigations:
CPU profile: where your program spends processor time. Use this when the symptom is “the service is slow” and you need to know which function is burning cycles.
Memory (heap) profile: where your program allocates memory. Use this when the symptom is “the service uses too much RAM” or “GC is running too often” or “we’re getting OOM-killed.”
Goroutine profile: where your goroutines are and what they’re doing. Use this when the symptom is “goroutine count is growing” or “the service is unresponsive” or “goroutine leak.”
Collecting a profile: two ways
Way 1: the net/http/pprof endpoint (for running services)
The simplest way to profile a running Go service: import net/http/pprof
and expose it on an HTTP endpoint. If your service already runs an HTTP
server, this is a two-line change:
import _ "net/http/pprof"
// in your main or init
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
Now your service exposes profiling data at localhost:6060/debug/pprof/.
Collect a 30-second CPU profile:
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
This downloads the profile and opens an interactive shell. The service runs normally while the profile is being collected: no restart, no code change, no special build flags.
Security note: never expose the pprof endpoint to the public internet. It gives anyone who can reach it deep insight into your program’s behavior. Bind it to localhost, or put it behind an auth proxy, or only enable it in non-production environments.
Way 2: runtime/pprof in code (for benchmarks and CLIs)
For programs that don’t run an HTTP server (CLI tools, batch jobs, tests), write the profile directly:
import "runtime/pprof"
func main() {
f, _ := os.Create("cpu.prof")
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
// ... your code here
}
Then analyze with:
go tool pprof cpu.prof
Reading a CPU profile
You’ve collected a profile. You’re staring at a wall of text. Here’s what to look for.
The interactive pprof shell shows you functions sorted by how much CPU
time they consume. The most useful commands:
(pprof) top 20
Showing nodes accounting for 3.2s, 80% of 4s
flat flat% sum% cum cum%
1.2s 30.0% 30.0% 1.2s 30.0% runtime.mallocgc
0.8s 20.0% 50.0% 0.8s 20.0% runtime.memclrNoHeapPointers
0.4s 10.0% 60.0% 0.6s 15.0% encoding/json.Marshal
...
flat is the time spent in the function itself (not its callees).
cum is the time spent in the function plus everything it calls. A
function with high flat is doing expensive work directly. A function with
high cum but low flat is calling expensive functions; the bottleneck
is in one of its callees.
The pattern to look for: high cum with low flat. That means the
function is slow because something it calls is slow. Drill down with
list <function> to see which line:
(pprof) list json.Marshal
Showing nodes accounting for 0.6s, 15% of 4s
Total: 4s
ROUTINE ======================== in encoding/json
0.4s 0.4s (flat, cum) 10.0% of Total
. . 87: e.init()
. . 88: e.p = e.pretty
0.4s 0.4s 89: e.reflectValue(v, encOpts{escapeHTML: true})
Line 89, reflectValue, is where the time goes. You’re spending 10% of
total CPU time in JSON reflection. The fix: reduce the amount of data
you’re marshaling, or use a faster JSON library like json-iterator or
sonic.
Reading a memory profile
A heap profile shows where your program allocates memory. The interactive
commands are the same (top, list, web) but the numbers mean
something different:
(pprof) top 10
Showing nodes accounting for 128MB, 64% of 200MB
flat flat% sum% cum cum%
64MB 32.0% 32.0% 64MB 32.0% github.com/company/service/models.(*Order).ToJSON
32MB 16.0% 48.0% 32MB 16.0% database/sql.(*DB).Query
16MB 8.0% 56.0% 16MB 8.0% fmt.Sprintf
flat here is bytes allocated, not time. The function at the top
(ToJSON) allocates 64MB, half your total heap. Drill down:
(pprof) list ToJSON
64MB 64MB 42: func (o *Order) ToJSON() []byte {
. . 43: data, _ := json.Marshal(o)
. . 44: return data
Every call to ToJSON allocates a new byte slice via json.Marshal. If
you’re marshaling thousands of orders per request, that’s a lot of
short-lived allocations that the garbage collector has to clean up. The fix:
reuse buffers with sync.Pool, or stream the JSON instead of materializing
the whole thing in memory.
In-use vs. alloc
The heap profile has two views: in-use (what’s allocated right now) and alloc (what was allocated since the program started). Switch between them:
(pprof) alloc_space # total allocated since start
(pprof) inuse_space # what's live right now
A function that shows up in alloc but not in-use allocates a lot of
memory that gets garbage collected quickly. That’s a GC pressure problem,
not a memory leak. A function that shows up in in-use allocates memory
that never gets freed. That’s a leak.
Reading a goroutine profile
A goroutine profile shows every live goroutine and where it’s blocked:
(pprof) top 10
1008 @ 0x43e236 0x40a3c5 0x40a3c5 0x46f8a5 0x4712b8 0x6d3f1a 0x472160
# 0x6d3f1a database/sql.(*DB).Query+0x13a /usr/local/go/src/sql/sql.go:1924
# 0x472160 service.(*OrderService).GetOrders+0x40 /app/service/orders.go:87
If you see hundreds of goroutines all stuck in the same place (say,
sql.(*DB).Query), you have a database connection pool exhaustion problem.
Your goroutines are all waiting for a database connection that isn’t
available. The fix: increase the pool size, or find the query that’s holding
connections open too long.
If goroutine count is growing over time (collect two profiles a minute apart and compare), you have a goroutine leak: goroutines that are spawned but never finish. The goroutine profile shows where they’re stuck, which tells you why they’re not finishing.
The practical workflow
Here’s the actual process when your service is slow:
Collect a CPU profile while the service is slow. Hit the pprof endpoint, wait 30 seconds, download the profile.
Run
top 20to see the top consumers. Look for functions with highcum: those are the bottlenecks.Drill down with
listto see which line in the function is expensive. Is it a database call? A JSON marshal? A regex? A lock?Fix the specific thing that’s slow. Not “rewrite the service.” Not “add a cache.” The specific function, the specific line.
Collect another profile after the fix to verify it actually helped.
The most common mistake: optimizing the wrong thing. You see “30% in
runtime.mallocgc” and think “GC is slow.” But mallocgc is high because
you’re allocating too much; the fix isn’t faster allocation, it’s fewer
allocations. Find what’s allocating (the alloc_space profile), not what’s
collecting.
The second most common mistake: profiling in development instead of production. A CPU profile under no load tells you where your program spends time when nothing is happening. Profile under real traffic to find the real bottlenecks. The pprof endpoint on localhost is safe for production; just don’t expose it publicly.
A worked example: finding a quadratic loop
MusicCorp’s order summary endpoint takes 2 seconds for 1,000 orders and 20 seconds for 10,000 orders. That’s quadratic: doubling the input quadruples the time.
CPU profile:
(pprof) top 5
8.0s 40.0% 40.0% 8.0s 40.0% service.(*OrderSummary).buildIndex
4.0s 20.0% 60.0% 4.0s 20.0% runtime.makeslice
Drill down:
(pprof) list buildIndex
8.0s 8.0s 31: for i, order := range orders {
0.0s 0.0s 32: for j, other := range orders {
8.0s 0.0s 33: if order.CustomerID == other.CustomerID {
0.0s 0.0s 34: index[order.CustomerID] = append(...)
0.0s 0.0s 35: }
0.0s 0.0s 36: }
Lines 32-36: a nested loop comparing every order to every other order. That’s O(n²). The fix: build the index with a single pass using a map:
for _, order := range orders {
index[order.CustomerID] = append(index[order.CustomerID], order)
}
Single pass, O(n). 20 seconds becomes 200 milliseconds. The profile told you exactly where the problem was. No guessing required.
pprof is the tool that turns “the service is slow” into “line 33 in
buildIndex is an O(n²) nested loop.” It’s built into Go, it works on
running services with zero code changes (via the HTTP endpoint), and it
takes thirty seconds to collect. The next time something is slow, reach for
the profile before reaching for the keyboard.
If you’re interested in seeing how profiling works at a lower level (reading binary output, tracing execution without source code), check out the reverse engineering series, which covers Ghidra, GDB, and binary analysis.