Skip to main content
Go advanced Lesson 21 of 25

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

Frequently Asked Questions

Does Go have manual memory management?
No. Go has a garbage collector that automatically frees memory. You allocate memory (via make, new, or composite literals) and the GC collects it when nothing references it anymore.
What is escape analysis?
Escape analysis is a compiler optimization that determines whether a variable's lifetime is bounded by its function (stack allocation — fast, no GC pressure) or whether it escapes to be referenced elsewhere (heap allocation — managed by GC). Use go build -gcflags='-m' to see the analysis.
What is sync.Pool used for?
sync.Pool is a cache of temporary objects that can be reused across calls, reducing GC pressure. It's used for frequently allocated objects like byte buffers. Objects in the pool may be collected at any GC cycle — Pool is a hint, not a guarantee.