Memory keeps climbing in a Go service under load. How do you find out why, and what can you actually tune?
Take a heap profile and compare two taken minutes apart, because the shape of the growth tells you whether it is a leak, a cache with no bound, or simply the collector letting the heap grow. The two real knobs are GOGC and GOMEMLIMIT, and reducing allocation matters more in Go than in a generational runtime.
What the interviewer is scoring
- Does the candidate distinguish a leak from a heap the collector has simply let grow
- Whether they reach for a differential heap profile rather than a single snapshot
- That GOGC is understood as a growth ratio rather than a size
- Whether GOMEMLIMIT is connected to the container being killed rather than to the collector
- Does the candidate name a realistic retention cause rather than describing the collector
Answer
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.
# With net/http/pprof mounted on an internal port.
curl -s localhost:6060/debug/pprof/heap > a.pprof
sleep 300
curl -s localhost:6060/debug/pprof/heap > b.pprof
# What grew between them, by what is still live.
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:
// Reads a 50 MB file and returns 20 bytes - and keeps the 50 MB alive,
// because the returned slice shares the original backing array.
func header(path string) []byte {
data, _ := os.ReadFile(path)
return data[:20]
}
// Copies out, so the large array becomes unreachable at the return.
func headerCopied(path string) []byte {
data, _ := os.ReadFile(path)
out := make([]byte, 20)
copy(out, data)
return 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
# BenchmarkEncode-8 250000 4812 ns/op 2048 B/op 14 allocs/op
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.
Likely follow-ups
- What does GOGC=50 actually change, and what does it cost you?
- Why does the process resident size stay high after the live heap shrinks?
- Which is worse in Go, a large heap or a high allocation rate, and why?
- How would a goroutine leak show up differently from a retained cache?
Related questions
- How do you tell whether a value escapes to the heap, and how would you find the allocations that are costing you?hardAlso on pprof and garbage-collection6 min
- A long-running Python service keeps climbing in memory and never comes back down. How do you work out where the memory went?hardAlso on memory and garbage-collection5 min
- How does the .NET garbage collector decide what to collect, and how would you make a hot path allocate less?hardAlso on garbage-collection and memory6 min
- A hot path allocates heavily and garbage collection is showing up in your profile. What does Span give you that a substring does not?hardAlso on memory and garbage-collection4 min
- What happens when you append to a slice, and when do two slices stop sharing memory?mediumAlso on memory and garbage-collection5 min
- How do goroutines leak, and how would you find a leak in a running service?mediumAlso on pprof5 min
- A service dies with OutOfMemoryError in production. Walk me through diagnosing it.hardAlso on garbage-collection5 min
- What is the difference between a value type and a reference type in C#, and what do nullable reference types guarantee?mediumAlso on memory5 min