The memory that never leaves
A background worker starts processing jobs. RAM usage sits steady at 200 megabytes for the first week. By week three, it climbs to 1.2 gigabytes. The application does not crash. It just responds slower, caches evict more often, and eventually the operating system kills it with an out-of-memory signal. You check the logs. Everything reports success. The garbage collector runs on schedule. Yet the memory never comes back.
This is a memory leak. In Go, leaks rarely look like dangling pointers or double frees. They look like references that outlive their usefulness. The garbage collector does exactly what it is told: it frees memory that is unreachable. If your program holds a reference to data it no longer needs, the collector assumes the data is still in use. The allocation stays on the heap. The process grows. The server degrades.
What a memory leak actually looks like in Go
Go manages memory automatically through a concurrent, generational garbage collector. You allocate with make, new, or composite literals. You never call free. The collector tracks reachability from root objects: global variables, goroutine stacks, and open file descriptors. Anything reachable stays alive. Anything unreachable gets reclaimed.
A leak happens when reachability persists longer than intended. Common patterns include unbounded caches, goroutines blocked on channels that never receive, slices that keep growing without eviction, and C allocations made through CGO that the Go collector cannot see. The collector is not broken. Your program is just holding onto memory it should have released.
Think of a library with automatic returns. The system only shelves books that are completely abandoned. If a patron leaves a single sticky note on the cover, the system assumes the book is still in circulation. The shelf space stays occupied. The library fills up. The leak is not the book. The leak is the sticky note.
The simplest way to catch it
The fastest path to a leak is a heap profile. You run your program or test suite, tell the runtime to record allocation sites, and compare snapshots. The Go toolchain includes everything you need. No external agents. No runtime overhead beyond the profiling hooks.
Here is the minimal setup to capture a heap profile during a test run:
package main
import (
"fmt"
"os"
"runtime/pprof"
)
// leakyCache simulates a cache that never evicts old entries.
var leakyCache []byte
func main() {
// Open a file to write the heap profile.
f, err := os.Create("heap.prof")
if err != nil {
panic(err)
}
defer f.Close()
for i := 0; i < 500000; i++ {
// Allocate a fresh 1KB buffer each iteration.
buf := make([]byte, 1024)
// Append keeps the old data alive in the backing array.
leakyCache = append(leakyCache, buf...)
}
// Force a GC cycle so the profile reflects live allocations.
runtime.GC()
// Write the current heap state to the file.
if err := pprof.WriteHeapProfile(f); err != nil {
panic(err)
}
fmt.Println("Profile written to heap.prof")
}
Run the program, then inspect the output with the built-in profiler:
go run main.go
go tool pprof -top heap.prof
The -top flag prints the allocation sites sorted by memory size. You will see which functions allocated the most bytes and how many times they called make or new. The profiler tracks both in-use memory and total allocated memory over the program lifetime. Total allocated memory is often more revealing for leaks, because it shows allocations that happened and were freed, plus the ones that stayed.
Goroutines are cheap. Heap profiles are not magic. They show you where memory lives. You still have to trace why it stays.
Walking through the profiler output
When you run go tool pprof -top heap.prof, the terminal prints a table. The first column shows the flat allocation size. The second shows the cumulative size including allocations made by callees. The third shows the percentage of total heap. The rest shows the function name and source file.
Look for functions that allocate large amounts of memory but do not correspond to your expected workload. A logging function allocating megabytes of byte slices is a red flag. A cache builder that never shrinks is another. The profiler also supports an interactive mode. Run go tool pprof heap.prof without flags to enter the console. Type top to see the table. Type list <function> to see the exact lines that allocated memory. Type web to open a graph in your browser.
The profiler samples allocations. It does not track every single byte. The sampling rate is usually 512 kilobytes. Small, frequent allocations may not appear. That is why you should force a garbage collection before writing the profile. The collector compacts the heap and updates the runtime's internal allocation counters. The profile then reflects the current live set accurately.
If you forget to import runtime/pprof, the compiler rejects the program with undefined: pprof. If you try to write a profile to a read-only directory, you get a plain open heap.prof: permission denied error. The toolchain does not hide failures. It tells you exactly what went wrong.
Real-world leak: goroutines and channels
The most common Go leak involves concurrency. A goroutine starts, blocks on a channel receive, and never wakes up. The goroutine holds its stack, any captured variables, and any open resources. The collector cannot free the goroutine because it is still reachable from the scheduler. The memory grows linearly with each spawned worker.
Here is a realistic pattern that leaks:
package main
import (
"fmt"
"time"
)
// worker simulates a background task that waits for jobs.
func worker(id int, jobs <-chan int) {
// Block until a job arrives or the channel closes.
for job := range jobs {
fmt.Printf("Worker %d processing job %d\n", id, job)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
// Create an unbuffered channel for job distribution.
jobs := make(chan int)
// Spawn ten workers that wait for jobs.
for i := 0; i < 10; i++ {
go worker(i, jobs)
}
// Send five jobs, then exit without closing the channel.
for i := 0; i < 5; i++ {
jobs <- i
}
// Wait briefly to let workers finish their current jobs.
time.Sleep(500 * time.Millisecond)
fmt.Println("Main exiting. Workers are still blocked.")
}
The program exits, but the five idle workers remain blocked on <-jobs. In a long-running server, those goroutines accumulate. Each one holds a few kilobytes of stack space. Over thousands of requests, that becomes hundreds of megabytes. The leak is not the channel. The leak is the missing close and the missing cancellation path.
Fix it by closing the channel when you are done sending, or by using a context to signal shutdown. Always give goroutines a way to wake up and return. The worst goroutine bug is the one that never logs.
Pitfalls and runtime surprises
Go's garbage collector only tracks memory allocated through Go's runtime. If you call C code through CGO, the Go collector has no visibility into those allocations. A C library that caches strings, opens file descriptors, or allocates buffers will leak from Go's perspective. The process memory climbs, but pprof shows nothing unusual.
To catch CGO leaks, compile with the AddressSanitizer. It instruments memory operations at compile time and tracks allocations across the Go and C boundaries. Build your binary with the -asan flag:
go build -asan -o myserver ./cmd/server
./myserver
When the process exits, LeakSanitizer scans the heap and prints a report. You will see lines like detected memory leaks followed by stack traces pointing to the exact C or Go function that allocated the memory. If you are running tests, set GODEBUG=asan=1 to enable the sanitizer without rebuilding. The overhead is significant. Use it for debugging, not production.
Another common pitfall is holding references in global maps. A map keyed by request ID that never gets cleaned up will grow until the server exhausts memory. The collector sees the map as a root. Every value inside is reachable. The leak is logical, not technical. You must implement eviction, TTLs, or periodic cleanup.
If you accidentally shadow a variable inside a loop and capture the loop variable in a closure, older Go versions would silently share the same variable across all closures. Go 1.22 changed this behavior. The compiler now rejects the pattern with loop variable i captured by func literal. The error forces you to explicitly copy the variable or use a function parameter. The language is getting stricter about accidental captures.
Choosing your leak detection tool
Use go tool pprof with heap profiles when you need to see which functions allocate the most memory and where live objects reside. Use GODEBUG=gctrace=1 when you want to monitor garbage collection frequency, pause times, and heap growth in real time without stopping the process. Use -asan with LeakSanitizer when your code calls CGO or links against C libraries that manage their own memory. Use manual tracing with runtime.Stack and debug.SetTraceback when you suspect goroutine leaks and need to dump blocked stacks during development. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.