First separate three different things
"Memory climbing" is three unrelated situations wearing one symptom, and picking
the wrong one wastes a day.
The heap is growing because the collector let it. Go's collector does not
run continuously; it starts a cycle when the heap has grown by a set proportion
over the live set. Under increasing load a heap that oscillates between 400 MB
and 800 MB is behaving correctly, and a graph of allocated bytes looks alarming
while nothing is wrong.
Something is retained that should not be. The live set itself grows and
never comes down. This is the actual leak, and the collector cannot help,
because everything it can see is reachable.
Memory is not the heap at all. Goroutine stacks, mmaped files, cgo
allocations and the runtime's own structures live outside it, so a process can
grow with a flat heap. A goroutine leak presents this way: each blocked
goroutine holds a stack and whatever it captured.
The first question to ask of a graph is therefore whether the troughs are
rising. A sawtooth with a flat floor is the collector working. A rising floor is
retention.
Get a differential heap profile
A single snapshot tells you what is on the heap, which is usually unsurprising.
The diagnostic is two snapshots and the difference between them.
curl -s localhost:6060/debug/pprof/heap > a.pprof
sleep 300
curl -s localhost:6060/debug/pprof/heap > b.pprof
go tool pprof -base a.pprof b.pprof
(pprof) top -cum
(pprof) list <suspect function>
The distinction that matters inside pprof is between the inuse and alloc
views. inuse_space is what is live now, which is what you want for a leak.
alloc_space is everything allocated since the process started, live or not,
which is what you want when the problem is allocation rate rather than
retention. Reading the second while hunting a leak sends you to whichever
function allocates most, which is frequently innocent.
For growth outside the heap, /debug/pprof/goroutine?debug=1 gives a count and
the stacks. A number that only ever rises is the answer on its own.
What actually retains memory
The collector frees what is unreachable, so a leak in Go is always something
still reachable that you forgot about. The causes are a short list.
| Cause | How it looks | Why it happens |
|---|
| Unbounded cache | Live heap rises in step with distinct keys seen | A map with no eviction, often added "temporarily" |
| Slice of a large array | Small live set, enormous heap | A sub-slice keeps the whole backing array alive |
| Goroutine leak | Goroutine count rises, heap follows | Blocked on a channel nobody will write to |
| Ticker or timer not stopped | Slow, steady growth | time.NewTicker without defer t.Stop() |
| Appending to a package-level slice | Linear growth with traffic | A metrics or audit buffer nobody drains |
| Finalisers or pooled objects held | Heap flat, resident size high | Objects returned to a pool and never reused |
The slice case is worth demonstrating because it is uniquely Go-shaped:
func header(path string) []byte {
data, _ := os.ReadFile(path)
return data[:]
}
[] {
data, _ := os.ReadFile(path)
out := ([], )
(out, data)
out
}
The two knobs, and what each is for
GOGC is a ratio, not a size. At its default of 100 the collector aims to
start a cycle when the heap has grown to twice the live set. Setting GOGC=50
collects at 1.5 times, which means a smaller heap and more frequent cycles —
more CPU spent collecting. Setting it higher trades memory for throughput. It is
the knob for the shape of the sawtooth.
GOMEMLIMIT, added in Go 1.19, is a soft ceiling on total memory and it
solves a different problem. GOGC alone knows nothing about how much memory
exists, so in a container with a hard limit the collector will happily let the
heap grow past it and the kernel will kill the process — an out-of-memory kill
the collector never saw coming, because from its point of view everything was
fine. Setting GOMEMLIMIT a little below the container limit makes the runtime
collect harder as it approaches, degrading throughput instead of dying.
The idiomatic modern configuration for a container is both: a GOMEMLIMIT near
the container's limit as a backstop, and GOGC left at its default, or disabled
entirely with the limit doing all the pacing when the workload's live set is
predictable.
One behaviour to expect and not be alarmed by: the process's resident size often
stays high after the live heap shrinks, because the runtime returns memory to
the operating system lazily. The heap can be small while the process still looks
large, which is another reason to trust the profile over the process table.
Why allocation rate matters more here
Go's collector is not generational. Most managed runtimes exploit the
observation that objects die young by collecting a cheap nursery frequently and
promoting survivors; Go's does not have one, so short-lived garbage costs more
here than the equivalent in a JVM.
The practical consequence is that reducing allocations is a first-class
optimisation in Go rather than a micro-optimisation, and the tooling supports it
directly. The compiler will explain each decision with go build -gcflags='-m',
and a benchmark reports allocations per operation:
go test -bench . -benchmem
The allocs/op column is the number to move. The usual wins are unglamorous:
preallocate a slice with make([]T, 0, n) when the size is known, reuse buffers
through a sync.Pool on a genuinely hot path, avoid the interface boxing that
fmt and any parameters cause, and pass large structs by pointer once copying
them shows up in a profile.
The discipline that keeps this honest is to measure first. An allocation in a
path that runs once at start-up is not worth a line of code.
A rising sawtooth floor is a leak; a rising sawtooth is the collector doing its
job. Everything else follows from which of those two you are looking at.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).