A function reads a huge buffer and returns just the small part the caller actually needs — a slice expression like big[:10], the obvious way to hand back ten bytes. The caller holds onto that small result for the rest of the program's life, confident the 100MB buffer behind it got garbage collected ages ago. It didn't. Nothing was ever wrong enough to panic or error — the memory is just quietly still there, for good.
func firstTen(big []byte) []byte {
return big[:10] // shares big's backing array -- ALL of it stays alive
}
var sink []byte
func main() {
func() {
big := make([]byte, 100_000_000) // 100MB
sink = firstTen(big)
}()
runtime.GC()
fmt.Println("kept len:", len(sink), "cap:", cap(sink))
fmt.Println("heap after GC (MB):", heapMB())
}
// kept len: 10 cap: 100000000
// heap after GC (MB): 95 -- the whole 100MB array is still reachableGo manages memory automatically with a concurrent, tri-color mark-and-sweep garbage collector, designed to run alongside your program with very short stop-the-world pauses rather than freezing execution for long collection cycles. Separately, the compiler performs escape analysis on every variable to decide whether it can be safely allocated on the function’s stack (fast, automatically freed on return) or must “escape” to the heap (managed by the garbage collector) because a reference to it outlives the function call.
Escape analysis: stack vs. heap
A value stays on the stack if the compiler can prove nothing outside the function can reference it after it returns; if a pointer to it is returned, stored in a global, or sent to a goroutine, it "escapes" to the heap.
func onStack() int {
x := 42 // never referenced outside; stays on the stack
return x
}
func onHeap() *int {
x := 42 // its address is returned, so it escapes to the heap
return &x // safe in Go (unlike C) — the GC keeps it alive
}You can see these decisions directly with go build -gcflags="-m", which prints each escape-analysis decision the compiler made.
The fix for the leak above follows directly from this: big[:10] is a slice expression, not a copy — the result still points into big's original backing array, so escape analysis correctly (and unhelpfully) keeps the whole 100MB array reachable for as long as sink is. The fix is to make an actual copy into a freshly allocated, small backing array, so nothing keeps pointing at the 100MB original:
func firstTen(big []byte) []byte {
out := make([]byte, 10)
copy(out, big[:10]) // copies into a fresh, small backing array
return out
}
func main() {
func() {
big := make([]byte, 100_000_000)
sink = firstTen(big)
}()
runtime.GC()
fmt.Println("kept len:", len(sink), "cap:", cap(sink))
fmt.Println("heap after GC (MB):", heapMB())
}
// kept len: 10 cap: 10
// heap after GC (MB): 0 -- the 100MB array is fully collectedNotice cap(sink) changes too: 100,000,000 in the buggy version, 10 in the fixed one. That capacity number is the tell — a small slice with a huge capacity is a sign it's still anchored to something much bigger than it looks.
Why the collector is concurrent
Go's GC runs mark and sweep phases concurrently with your program's goroutines, using short stop-the-world pauses (typically well under a millisecond) only for phase transitions rather than pausing for the entire collection. This matters specifically because Go programs are often long-running network services, where a multi-second GC pause would show up as a visible latency spike to users.
Tuning with GOGC and GOMEMLIMIT
The GC's aggressiveness can be tuned with the GOGC environment variable (default 100, meaning "collect again once heap size doubles since the last collection") and, since Go 1.19, GOMEMLIMIT, which sets a soft memory cap the GC works to stay under — useful in containerized environments with a fixed memory limit.
$ GOGC=50 ./myserver // collect more aggressively (smaller heap, more CPU)
$ GOMEMLIMIT=512MiB ./myserver // soft cap, useful inside a memory-limited container