Memory, Escape Analysis & the Garbage Collector
Prerequisites: Reflection & struct tags
Go manages memory for you, but where a value lives affects performance. Knowing the basics helps you read profiles and avoid needless allocations — without prematurely optimising.
Stack vs heap
Each goroutine has its own stack, which grows as needed. Values on the stack are freed automatically when the function returns, at almost no cost. Values on the heap outlive the function that created them and are reclaimed later by the garbage collector.
You don't choose. The compiler's escape analysis decides: if it can prove a value isn't referenced after the function returns, it stays on the stack; otherwise it "escapes" to the heap. Returning a pointer to a local is perfectly safe in Go — the compiler just moves that value to the heap.
Seeing escape decisions
package main
import "fmt"
type Point struct{ X, Y int }
func newPoint(x, y int) *Point {
p := Point{x, y}
return &p // p outlives the call, so it escapes
}
func sum(p Point) int {
return p.X + p.Y // p is copied in and stays on the stack
}
func main() {
p := newPoint(1, 2)
fmt.Println(sum(*p))
}
Build with -gcflags=-m to print the compiler's decisions:
go build -gcflags=-m .
With Go 1.23 the output is:
./main.go:7:6: can inline newPoint
./main.go:12:6: can inline sum
./main.go:17:15: inlining call to newPoint
./main.go:18:17: inlining call to sum
./main.go:18:13: inlining call to fmt.Println
./main.go:8:2: moved to heap: p
./main.go:18:13: ... argument does not escape
./main.go:18:17: ~r0 escapes to heap
Reading it:
moved to heap: p— insidenewPoint,pescapes because its address is returned. BecausenewPointis small, the compiler also inlines it intomain, where it can see the pointer never leaves, so the inlined copy ofpstays on the stack. Inlining and escape analysis work together.~r0 escapes to heap—~r0issum's unnamed result. Passing it tofmt.Printlnconverts it to an interface value, which here forces a heap allocation.
Common causes of escaping:
- returning or storing a pointer to a local variable;
- assigning to an interface value (including
...anyparameters); - closures that capture variables and outlive the function;
- slices whose size isn't known at compile time, or that are very large.
The garbage collector
Go's GC is concurrent and non-generational, tuned for low pause times rather than maximum throughput. Two knobs control it:
GOGC(default100) — start a collection when the heap has grown by this percentage since the last one. Higher means fewer collections and more memory;GOGC=offdisables the GC.GOMEMLIMIT(Go 1.19+) — a soft memory limit such asGOMEMLIMIT=512MiB. As the heap approaches it, the GC runs more often. Setting it slightly below a container's memory limit helps avoid out-of-memory kills.
Both can also be set in code via runtime/debug.SetGCPercent and
debug.SetMemoryLimit. Leave them at defaults until a profile tells you
otherwise.
Reducing allocations
Fewer allocations means less GC work. The usual wins:
// 1. Preallocate when you know the size.
out := make([]string, 0, len(users))
for _, u := range users {
out = append(out, u.Name)
}
// 2. Build strings with strings.Builder instead of += in a loop.
var b strings.Builder
b.Grow(64)
for _, part := range parts {
b.WriteString(part)
}
s := b.String()
// 3. Reuse short-lived buffers with sync.Pool.
var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
// ... use buf ...
bufPool.Put(buf)
You can measure allocations directly — testing.AllocsPerRun works even
outside tests:
package main
import (
"fmt"
"testing"
)
var sink []int
func main() {
grow := testing.AllocsPerRun(100, func() {
var s []int
for i := 0; i < 1000; i++ {
s = append(s, i)
}
sink = s
})
pre := testing.AllocsPerRun(100, func() {
s := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
s = append(s, i)
}
sink = s
})
fmt.Println(grow > pre, pre)
}
Expected output:
true 1
The growing slice reallocates repeatedly as it doubles; the preallocated one
allocates once. (Assigning to the package-level sink stops the compiler from
optimising the work away.)
Common mistakes
- Optimising allocations before profiling. Most code is fast enough; measure with benchmarks and pprof (next lessons) first.
- Using pointers everywhere "to avoid copying". Small structs are cheap to copy, and pointers can cause heap allocations.
- Keeping a small slice of a huge array alive — the whole backing array stays
in memory. Copy the part you need (
slices.Clone). - Putting objects of wildly different sizes in the same
sync.Pool, or relying on it as a cache — the GC may empty it at any time.
Practice
Rewrite this function so it performs a single allocation for the result (assume ASCII input):
func joinUpper(words []string) string {
s := ""
for _, w := range words {
s += strings.ToUpper(w) + " "
}
return s
}
Show solution
package main
import (
"fmt"
"strings"
)
func joinUpper(words []string) string {
n := 0
for _, w := range words {
n += len(w) + 1
}
var b strings.Builder
b.Grow(n)
for _, w := range words {
for i := 0; i < len(w); i++ {
c := w[i]
if 'a' <= c && c <= 'z' {
c -= 'a' - 'A'
}
b.WriteByte(c)
}
b.WriteByte(' ')
}
return b.String()
}
func main() {
fmt.Printf("%q\n", joinUpper([]string{"go", "is", "fun"}))
}
Expected output:
"GO IS FUN "
Grow reserves the exact size up front. Converting byte-by-byte avoids the
temporary string that strings.ToUpper would allocate for each word.