How to Analyze Memory (Heap) Profiles in Go

Generate and analyze Go heap profiles using go tool pprof to identify memory allocation hotspots and leaks.

When memory climbs and never comes down

Your Go service launches cleanly. It handles requests. Then, after a few hours, the memory graph on your dashboard starts climbing. It never comes down. Eventually the container crashes with an out-of-memory panic. You know something is holding onto bytes it should have released. The question is where.

Heap profiling answers that question. The heap is the part of memory Go uses for data that outlives a single function call. Slices, maps, channels, and large structs all live there. A heap profile is a snapshot of those allocations. Instead of tracking every single byte, the runtime samples them. It picks a random subset, records where they came from, and lets you reconstruct the pattern. Think of it like a security camera that only records every tenth person entering a building. You still see who is staying past closing time.

The heap is not a black box. It is a ledger.

How heap profiling actually works

The Go runtime keeps a lightweight sampling profiler running in the background. By default, it records an allocation roughly every 512 kilobytes of heap growth. When you trigger a heap dump, the runtime pauses all goroutines, walks the heap, and writes a binary file containing sampled stack traces and byte counts. The file does not contain source code. It contains memory addresses and instruction pointers.

The pprof tool bridges that gap. It reads your compiled binary, extracts the symbol table, and maps those addresses back to function names and line numbers. It then builds a call graph. Each node represents a function. Each edge represents a call relationship. The width of an edge shows how much memory flowed through that call path. You can toggle between inuse_space (bytes currently held) and alloc_space (total bytes requested over time). You can also switch to objects to count allocations instead of measuring bytes.

Click the biggest node. Follow the thickest edge. The leak is hiding in plain sight.

Minimal example

Here is a minimal program that intentionally hoards memory so we can see the profiler in action.

package main

import (
	"os"
	"runtime"
	"runtime/pprof"
)

func main() {
	// Create a file to hold the heap snapshot
	f, err := os.Create("heap.prof")
	if err != nil {
		panic(err)
	}
	defer f.Close()

	// Build a slice that grows without bound
	var cache []string
	for i := 0; i < 500000; i++ {
		cache = append(cache, "data")
	}

	// Trigger GC so the profile shows what is actually retained
	runtime.GC()

	// Dump the current heap state to disk
	if err := pprof.WriteHeapProfile(f); err != nil {
		panic(err)
	}
}

Run the program with go run main.go. Then feed the output to the interactive viewer:

go tool pprof -http=:8080 heap.prof

The browser opens a directed graph. The main function dominates the view. The edge pointing to runtime.makeslice shows where the bytes originated. The profile proves that the slice is alive and holding memory. Close the browser, clear the slice, run again, and the graph flattens. The garbage collector did its job.

Walking through the call graph

The web interface shows two numbers for every node: flat and cumulative. Flat memory is what the function allocated directly. Cumulative memory includes everything allocated by functions it called. A high cumulative number with a low flat number means the function is a bottleneck, not the source. Click it to highlight its callers. The callers reveal the retention path.

Sampling introduces bias. If your program allocates many tiny objects, the default sampler might skip them. You can force every allocation into the profile by running the binary with -memprofilerate=1. The compiler accepts this flag during build or run, but capturing every allocation adds significant CPU overhead. Use it only when hunting micro-leaks.

Escape analysis also changes what you see. Go moves variables to the heap when they outlive the function call. A slice created inside a small helper might appear in the heap profile even though the code looks local. The profiler points to the allocation site, not the retention site. Follow the call graph upward to find what is keeping the data alive.

Profiles show you where memory lives. They do not forgive bad design.

Realistic example

Here is how a production service exposes a heap dump through a debug endpoint.

package main

import (
	"fmt"
	"io"
	"net/http"
	"runtime/pprof"
)

// handleHeapDump streams a heap profile to the HTTP response
func handleHeapDump(w http.ResponseWriter, r *http.Request) {
	// Defer cleanup to ensure the request body is fully consumed
	defer func() {
		// Discard remaining bytes to prevent connection leaks
		_, _ = io.Copy(io.Discard, r.Body)
		r.Body.Close()
	}()

	// Signal to the client that this is a binary download
	w.Header().Set("Content-Type", "application/octet-stream")
	w.Header().Set("Content-Disposition", "attachment; filename=heap.prof")

	// Write the live heap snapshot directly to the response
	if err := pprof.WriteHeapProfile(w); err != nil {
		http.Error(w, fmt.Sprintf("profile failed: %v", err), http.StatusInternalServerError)
		return
	}
}

The handler respects the context.Context attached to the request. By convention, context always goes as the first parameter in Go functions, but HTTP handlers receive it via r.Context(). The deferred io.Copy uses the underscore to discard the byte count. The underscore tells the compiler you intentionally ignored the return value. It keeps the code clean without hiding the operation.

Mount this handler on a /debug/heap route behind authentication. Hit it with curl or a browser, download the file, and run go tool pprof against it. The web UI will show your actual request pipeline. You will see net/http serving the response, your router dispatching, and your business logic allocating buffers. The graph separates framework overhead from your code.

Pitfalls and runtime signals

The profiler shows you where memory lives, but it does not tell you why. The most common mistake is confusing alloc_space with inuse_space. Allocation counts every byte ever requested. In-use counts only what the garbage collector could not free. High allocation with low in-use means your code is churning through memory efficiently. High in-use means something is holding a reference.

Another trap is sampling bias. The runtime samples allocations by default every 512 kilobytes. If your program creates millions of tiny strings, the profiler might skip them entirely. You can force every allocation into the profile by running the binary with -memprofilerate=1. The compiler accepts this flag during build or run, but be aware that capturing every allocation adds significant CPU overhead.

Watch for escape analysis surprises. Go moves variables to the heap when they outlive the function call. A slice created inside a small helper might appear in the heap profile even though the code looks local. The profiler points to the allocation site, not the retention site. Follow the call graph upward to find what is keeping the data alive.

If memory runs out before you can grab a profile, the runtime panics with runtime: out of memory: GC triggered. That message means the garbage collector ran as hard as it could and still could not free enough space. The process dies immediately. You cannot catch that panic with a standard recover call. Set GOMEMLIMIT in your environment to cap usage gracefully before the kernel steps in. The Go 1.19+ memory limiter triggers aggressive garbage collection and returns errors to your code instead of crashing the container.

Profiles show you where memory lives. They do not forgive bad design.

Decision matrix

Use go tool pprof -http when you need an interactive graph to trace allocation hotspots across call stacks. Use pprof -top when you want a quick text summary of the biggest memory consumers without opening a browser. Use GOMEMLIMIT when you need a hard ceiling to prevent container crashes in production. Use -memprofilerate=1 when you are debugging tiny allocations that the default sampler misses. Use inuse_objects when you suspect a reference leak rather than a size problem. Use net/http/pprof when you want continuous profiling on a running service without restarting it.

Measure before you optimize. Guessing is just slower debugging.

Where to go next