Go manages memory automatically through a garbage collector. You allocate, the GC frees. But understanding where values live (stack vs heap), how the GC works, and how to reduce allocation pressure separates code that runs fast from code that triggers GC pauses every few milliseconds.
For profiling allocation hotspots see Go performance tuning systems and Go profiling CPU and memory.
Stack vs Heap
Every value in Go lives either on the stack (current goroutine’s call stack) or the heap (GC-managed memory). Stack allocation is effectively free — the stack pointer moves by the size of the allocation. Heap allocation requires the allocator to find suitable memory and register the pointer for GC scanning.
The compiler decides through escape analysis:
go build -gcflags="-m" ./...
# ./main.go:10:6: moved to heap: user ← heap allocation
# ./main.go:15:6: x does not escape ← stack allocation
A value “escapes to the heap” when:
- Its address is returned from the function that created it
- It’s assigned to a variable of interface type
- It’s captured by a closure that outlives the current function
- It’s passed to a function that stores the pointer
// ❌ Escapes: address returned to caller — must live on heap
func newUser() *User {
u := User{Name: "Alice"}
return &u
}
// ✅ Doesn't escape: value returned, no address taken
func newUser() User {
return User{Name: "Alice"}
}
// ❌ Escapes: passed to interface — stored as any, GC must track it
var cache map[string]any
cache["user"] = User{Name: "Alice"} // User escapes into interface
// ✅ Doesn't escape: typed map, no interface boxing
var cache map[string]User
cache["user"] = User{Name: "Alice"}
Reading Memory Statistics
runtime.ReadMemStats gives you a snapshot of the allocator’s state:
var m runtime.MemStats
runtime.ReadMemStats(&m)
slog.Info("memory",
slog.Uint64("heap_alloc_mb", m.HeapAlloc/1<<20), // live heap objects
slog.Uint64("heap_sys_mb", m.HeapSys/1<<20), // total heap from OS
slog.Uint64("heap_objects", m.HeapObjects), // live object count
slog.Uint64("total_alloc_mb", m.TotalAlloc/1<<20), // cumulative; never decreases
slog.Uint32("gc_cycles", m.NumGC), // total GC runs
slog.Duration("gc_pause_total", time.Duration(m.PauseTotalNs)),
)
Key metrics to watch:
HeapAlloctrending up without traffic increase → memory leakTotalAllocgrowing much faster thanHeapAlloc→ high allocation churnNumGCgrowing rapidly → frequent GC, consider reducing allocationsPauseTotalNs / NumGC→ average GC pause; target < 1ms
sync.Pool: Amortizing Allocation Cost
For objects that are created frequently and discarded quickly, sync.Pool amortizes allocation by reusing them:
var bufPool = sync.Pool{
New: func() any {
return bytes.NewBuffer(make([]byte, 0, 4096))
},
}
func formatJSON(data any) ([]byte, error) {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset() // clear content, keep capacity
defer bufPool.Put(buf)
if err := json.NewEncoder(buf).Encode(data); err != nil {
return nil, err
}
return bytes.Clone(buf.Bytes()), nil // copy before returning buf to pool
}
Important: sync.Pool objects may be collected by the GC at any time (between GC cycles). Don’t use it for objects that must survive — use a channel-based pool or explicit free list for those.
Always Reset() or clear the object before putting it back in the pool. Returning a dirty object means the next borrower gets stale data.
Pre-allocation: Avoiding Repeated Reallocation
append doubles the slice capacity when it runs out of room. For N elements, this causes O(log N) reallocations. Pre-allocating eliminates all of them:
// ❌ O(log N) reallocations
var result []User
for _, row := range rows {
result = append(result, scanUser(row))
}
// ✅ One allocation
result := make([]User, 0, len(rows))
for _, row := range rows {
result = append(result, scanUser(row))
}
// ✅ Pre-sized maps too
counts := make(map[string]int, len(items))
for _, item := range items {
counts[item]++
}
GC Tuning: GOGC and GOMEMLIMIT
GOGC (default: 100) controls when the GC runs. It’s the percentage of new heap allocated since the last GC. Setting GOGC=200 means the GC runs when the heap doubles, not when it grows by 100%.
# Double the heap target — GC runs half as often, but uses 2x memory
GOGC=200 ./myapp
# Disable GC entirely (useful for short-lived batch jobs)
GOGC=off ./batch-job
GOMEMLIMIT (Go 1.19+) sets a soft memory limit. The GC becomes more aggressive as the process approaches the limit, preventing OOM kills in memory-constrained environments:
GOMEMLIMIT=512MiB ./myapp
Or in code:
import "runtime/debug"
debug.SetMemoryLimit(512 << 20) // 512 MiB
GOMEMLIMIT is now the preferred way to tune GC behavior in container environments. Set it to ~75% of the container memory limit to leave headroom for non-heap memory.
Diagnosing Memory Leaks
A memory leak in Go means something is holding references to data that should be freed. Common sources:
Goroutine leak — goroutines blocked on channels or locks they’ll never unblock:
# Check goroutine count
curl http://localhost:6060/debug/pprof/goroutine?debug=1
Global map growing without bound — cache without eviction:
// ❌ Grows forever
var cache = map[string]*Data{}
// ✅ Bounded with eviction or use expiring cache library
Large slice keeping small slice alive — holding a sub-slice keeps the whole backing array:
// ❌ data is huge; result holds a reference keeping all of data alive
result := data[0:10]
// ✅ Copy the small portion
result := make([]byte, 10)
copy(result, data[:10])
Use heap profiles to find the leak:
# Take heap snapshot
go tool pprof http://localhost:6060/debug/pprof/heap
# In pprof interactive mode:
# (pprof) top20 -inuse_objects ← most objects retained
# (pprof) top20 -inuse_space ← most bytes retained
# (pprof) list packagename.Function ← line-by-line view
The GC Concurrently Marks, STW Sweeps
Go’s GC is concurrent — most of the work (marking live objects) runs alongside your program. Only two brief stop-the-world pauses occur per GC cycle:
- Start of marking — typically < 100µs
- End of marking — typically < 1ms
The sweep phase (freeing dead objects) is entirely concurrent.
If your program has long GC pauses, the usual cause is too many objects to scan (large heaps with many pointers) or too many allocations per second (forcing frequent GC). Fix: reduce allocation rate and heap size via the techniques above.
Summary
- Escape analysis decides stack vs heap;
-gcflags="-m"shows what escapes — reduce escapes for hot paths sync.Poolamortizes allocation for frequently-created/discarded objects; always reset before returning- Pre-allocate slices with
make([]T, 0, n)and maps withmake(map[K]V, n)when size is known GOMEMLIMITis the modern way to control GC pressure in containers — set to ~75% of container limit- Diagnose memory leaks with heap pprof:
top20 -inuse_spaceshows what’s retaining memory - Sub-slices keep the entire backing array alive — copy if you only need a small portion
Resources
- Go GC Guide
- runtime/debug.SetMemoryLimit
- sync.Pool documentation
- Go Blog: Getting to Go: The Journey of Go’s Garbage Collector
Comments