Skip to main content

Memory Management and Allocation in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 6 min read

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:

  • HeapAlloc trending up without traffic increase → memory leak
  • TotalAlloc growing much faster than HeapAlloc → high allocation churn
  • NumGC growing rapidly → frequent GC, consider reducing allocations
  • PauseTotalNs / 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:

  1. Start of marking — typically < 100µs
  2. 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.Pool amortizes allocation for frequently-created/discarded objects; always reset before returning
  • Pre-allocate slices with make([]T, 0, n) and maps with make(map[K]V, n) when size is known
  • GOMEMLIMIT is the modern way to control GC pressure in containers — set to ~75% of container limit
  • Diagnose memory leaks with heap pprof: top20 -inuse_space shows what’s retaining memory
  • Sub-slices keep the entire backing array alive — copy if you only need a small portion

Resources

Comments

👍 Was this article helpful?