What are common causes of memory leaks in a long-running Go program?
Quick Answer
Even with a garbage collector, Go programs can leak memory whenever something keeps a reference alive that should have been released — the GC only reclaims memory nothing points to anymore, so a lingering reference prevents collection just as surely as manual memory management would. Common causes: goroutine leaks (a blocked goroutine holds onto whatever it closed over, forever); growing a global slice or map and never removing old entries (like an unbounded in-memory cache); a large slice sub-sliced from a small piece, where the small sub-slice keeps the entire original backing array alive since they share memory; and forgetting to unregister callbacks, subscriptions, or timers that hold a reference back to an object that should otherwise be garbage.
Detailed Answer
"Go has a garbage collector" doesn't mean memory leaks are impossible. It means leaks always come from something staying reachable that shouldn't.
Goroutine leaks holding references
func startWatcher(largeCache *Cache) {
go func() {
for {
<-someChannelThatNeverReceives // blocks forever
largeCache.Refresh()
}
}()
}
As long as this goroutine is blocked and alive, it keeps largeCache reachable, and the GC can never reclaim it, even if every other reference to largeCache elsewhere in the program has gone away.
An unbounded cache or map
var cache = map[string][]byte{}
func handleRequest(key string, data []byte) {
cache[key] = data // never evicted — grows forever
}
Without an eviction policy (size cap, TTL, LRU), a long-running server accumulates cache entries indefinitely, which looks exactly like a leak from an operational standpoint even though nothing is technically "wrong" with the code's correctness.
Sub-slicing a large backing array
func extractHeader(data []byte) []byte {
return data[:16] // shares data's entire backing array
}
huge := loadGigabyteFile()
header := extractHeader(huge)
// huge itself might otherwise be garbage, but header keeps its ENTIRE backing array alive
Even though header only needs 16 bytes, it keeps the full multi-gigabyte backing array reachable, since slices share memory with what they were sliced from. Fix it with an explicit copy:
func extractHeader(data []byte) []byte {
header := make([]byte, 16)
copy(header, data[:16])
return header // huge's backing array can now be collected independently
}
Forgotten callback/subscription registrations
eventBus.Subscribe(func(e Event) { obj.Handle(e) }) // never unsubscribed
If eventBus outlives obj and nothing ever unsubscribes this closure, obj stays reachable through the closure indefinitely, even after every other part of the program has moved on and forgotten about it.
The diagnostic approach
Take pprof's heap profile (go tool pprof http://host/debug/pprof/heap) at two different points in time, then diff them. This reliably shows which allocations are growing unboundedly, usually faster than guessing from code review alone.