Memory Management in Go
Understand Go's garbage collector, stack vs heap allocation, escape analysis, profiling with pprof, and sync.Pool for object reuse.
Stack vs Heap
Go automatically decides where to allocate each variable — you never call malloc or free. The compiler places variables on the stack when their lifetime is bounded by the function call: the memory is claimed when the function is called and released when it returns, with no garbage collector involvement at all. When a variable’s lifetime may outlast its function — because a pointer to it is returned, stored in a struct, or captured by a goroutine — the compiler moves it to the heap, where the GC will eventually reclaim it. Understanding this distinction is the foundation of Go performance tuning.
// Stack allocation — x lives only as long as stackOnly() is on the call stack
// Fast: no allocation overhead, no GC pressure
func stackOnly() {
x := 42
fmt.Println(x) // x used only here, never leaves the function
}
// Heap allocation — x escapes because we return its address
// The caller holds a reference that outlives this function call
func heapAlloc() *int {
x := 42
return &x // x must survive after heapAlloc returns — compiler moves it to the heap
}
Escape Analysis
The compiler performs escape analysis at compile time to decide where each variable goes. You can inspect these decisions with the -m flag — it’s invaluable when optimising hot paths. The general principle: keep data on the stack by avoiding returning pointers, storing values in interfaces, and capturing variables in goroutine closures where possible.
go build -gcflags='-m' ./...
# ./main.go:10:2: x escapes to heap
# ./main.go:15:6: &y does not escape
// Does not escape — compiler sees the pointer is not used after the function returns
func noEscape() int {
p := new(int)
*p = 42
return *p // we return the value, not the pointer — p stays on the stack
}
// Escapes — pointer is returned to the caller
func escapes() *int {
p := new(int)
*p = 42
return p // p must outlive this function — allocated on the heap
}
// Escapes — storing a concrete value in an interface causes heap allocation (boxing)
func boxed(v int) interface{} {
return v // v is wrapped in an interface value — allocated on the heap
}
The Go Garbage Collector
Go’s garbage collector runs concurrently with your program, so it never stops the world for long. It uses a tri-color mark-and-sweep algorithm: GC goroutines mark live objects white, grey, and black while your program continues executing. Only two brief stop-the-world pauses are needed per cycle — modern Go programs typically see GC pauses under 1ms. The main tuning lever is GOGC: it controls how much the heap is allowed to grow before the next GC cycle triggers.
import "runtime/debug"
// GOGC=50 means GC runs when heap grows 50% — more frequent GC, lower peak memory
debug.SetGCPercent(50)
// GOGC=-1 disables GC entirely — useful for batch jobs or benchmarks
debug.SetGCPercent(-1)
// Force an immediate GC cycle — useful in benchmarks before measuring allocations
runtime.GC()
// Read current memory statistics
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
fmt.Printf("Alloc: %d KB\n", stats.Alloc/1024) // current heap in use
fmt.Printf("TotalAlloc: %d KB\n", stats.TotalAlloc/1024) // cumulative bytes allocated
fmt.Printf("NumGC: %d\n", stats.NumGC) // number of GC cycles completed
Go 1.21+ introduced a soft memory ceiling — the GC will run more aggressively to stay under this limit:
GOMEMLIMIT=512MiB ./myapp # GC kicks in before heap reaches 512 MB
Profiling with pprof
Before optimising memory, you need to know where allocations are actually happening. Guessing is unreliable — the hotspot is rarely where you expect. pprof is Go’s built-in profiler. Adding the blank import to your HTTP server registers profiling endpoints at /debug/pprof/ automatically. In production, protect these endpoints behind authentication or bind only to a localhost port.
import _ "net/http/pprof" // registers /debug/pprof/* routes as a side effect
func main() {
go http.ListenAndServe(":6060", nil) // profiling server — separate port from app
// ...
}
# CPU profile — which functions consume the most CPU time?
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
# Heap profile — which functions allocate the most memory?
go tool pprof http://localhost:6060/debug/pprof/heap
# Goroutine profile — how many goroutines exist and what are they blocked on?
go tool pprof http://localhost:6060/debug/pprof/goroutine
# In pprof interactive mode:
# top — list top consumers by cumulative CPU or allocation
# list Func — show annotated source lines for a specific function
# web — open a flame graph in the browser (requires graphviz)
For tests and benchmarks:
go test -cpuprofile cpu.prof -memprofile mem.prof -bench=.
go tool pprof cpu.prof
sync.Pool — Reusing Objects
Every time your code allocates a temporary byte buffer, formats a string, or builds an HTTP response body, it puts pressure on the GC. If the same allocation happens thousands of times per second, GC cycles become frequent and throughput suffers. sync.Pool solves this by keeping a reusable stock of objects. Instead of allocating a fresh buffer for each request, you borrow one from the pool and return it when done. The pool is not a guaranteed cache — objects may be collected at any GC — but in practice it dramatically reduces allocation rates for short-lived objects.
import "sync"
// bufPool holds reusable 64 KB byte slices for request processing
var bufPool = sync.Pool{
New: func() interface{} {
// New is called only when the pool is empty — allocate a fresh buffer
return make([]byte, 0, 64*1024) // 64 KB initial capacity
},
}
func processRequest(data []byte) {
buf := bufPool.Get().([]byte) // borrow a buffer from the pool
buf = buf[:0] // reset length to 0, keeping the underlying capacity
defer bufPool.Put(buf) // return to pool when done — not freed, reused next time
buf = append(buf, data...)
// ... process buf ...
}
Do not use sync.Pool for objects that hold resources requiring explicit cleanup (database connections, file handles, network connections) — use a dedicated pool or connection manager instead.
Reducing Allocations
Preallocate slices and maps
When you know the final size upfront, preallocating with make is the single most impactful allocation optimisation available. Without a size hint, append doubles the backing array repeatedly as it grows — each doubling copies all existing elements to a new allocation. With the right initial capacity, there is exactly one allocation.
// Inefficient — repeated reallocations as the slice grows beyond each capacity threshold
func buildSliceBad(n int) []int {
var result []int
for i := 0; i < n; i++ {
result = append(result, i*i) // may reallocate and copy on every power-of-two boundary
}
return result
}
// Efficient — single allocation, no copying
func buildSliceGood(n int) []int {
result := make([]int, n) // allocate with exact final length
for i := 0; i < n; i++ {
result[i] = i * i
}
return result
}
Avoid interface boxing for hot paths
Passing a concrete value where an interface is expected causes the value to be heap-allocated (boxed). In hot paths — tight loops, per-request processing — this adds up quickly. The fix is to write directly to a concrete type like strings.Builder or bytes.Buffer rather than passing values through interface{}.
// Each fmt.Println call boxes x into an interface{} — allocates on the heap
fmt.Println(x)
// Zero allocation — write directly to a concrete type
var b strings.Builder
fmt.Fprintf(&b, "%d", x)
Use value types instead of pointers for small structs
Passing small structs by value avoids heap allocation entirely. The rule of thumb: if the struct fits in two or three cache lines (~32–48 bytes) and you don’t need to modify it, pass by value. The copy is cheaper than allocating on the heap and letting the GC track it.
type Point struct{ X, Y float64 } // 16 bytes — pass by value is cheap
// Passing by value: no allocation, no pointer indirection, GC-free
func distance(a, b Point) float64 {
dx, dy := b.X-a.X, b.Y-a.Y
return math.Sqrt(dx*dx + dy*dy)
}
strings.Builder vs concatenation
String concatenation with + creates a new string on every iteration because strings are immutable in Go. For N strings, that’s O(N²) total bytes copied. strings.Builder accumulates writes into a single growing buffer and produces the final string with one copy at the end.
// O(n²) — each += creates a new string and copies all previous content
func joinBad(words []string) string {
result := ""
for _, w := range words {
result += w + " " // allocates a new string on every iteration
}
return result
}
// O(n) — one allocation, one copy at the end
func joinGood(words []string) string {
var b strings.Builder
b.Grow(len(words) * 8) // hint at expected final size — avoids internal reallocations
for _, w := range words {
b.WriteString(w)
b.WriteByte(' ')
}
return b.String()
}
Practical Benchmark Comparison
Benchmarks quantify the real cost of implementation choices. Run with -benchmem to see allocations per operation alongside time — often the allocation count tells you more than the nanosecond count. Here the Builder approach is roughly 3x faster and uses 8x fewer allocations.
package main
import (
"strings"
"testing"
)
func BenchmarkConcatPlus(b *testing.B) {
words := strings.Fields("the quick brown fox jumps over the lazy dog")
b.ResetTimer()
for i := 0; i < b.N; i++ {
result := ""
for _, w := range words {
result += w // each iteration allocates a new string
}
_ = result
}
}
func BenchmarkConcatBuilder(b *testing.B) {
words := strings.Fields("the quick brown fox jumps over the lazy dog")
b.ResetTimer()
for i := 0; i < b.N; i++ {
var sb strings.Builder
for _, w := range words {
sb.WriteString(w) // writes to an internal buffer — no allocation per call
}
_ = sb.String()
}
}
// go test -bench=. -benchmem
// BenchmarkConcatPlus-8 3000000 412 ns/op 432 B/op 8 allocs/op
// BenchmarkConcatBuilder-8 8000000 148 ns/op 56 B/op 1 allocs/op