Skip to main content
Go · lesson 21 of 25
Go advanced Lesson 21 of 25

Memory Management in Go

Go memory run for real: escape analysis with -gcflags=-m, counting allocations, gctrace and GOGC, sync.Pool and preallocation benchmarks, pprof, a leak.

By SkillByExample · Updated

Go manages memory for you, but where a value lives — on a goroutine’s stack or on the garbage-collected heap — decides how much work the collector has to do. This lesson shows the compiler’s decisions, counts allocations, watches the collector run, and finds where memory goes. Any timing or byte count below came from one run on this machine (Go 1.24.7, linux/amd64, except where a section says otherwise) and will vary on yours and between runs; the allocation counts are deterministic.

Escape analysis: stack or heap

The compiler keeps a value on the stack when it can prove the value isn’t used after the function returns. -gcflags=-m prints its reasoning.

package main

import "fmt"

type Point struct{ X, Y int }

func byValue() Point { return Point{1, 2} }

func byPointer() *Point { return &Point{3, 4} }

func sumLocal() int {
	nums := make([]int, 8)
	for i := range nums {
		nums[i] = i
	}
	return nums[7]
}

func dynamic(n int) []int { return make([]int, n) }

func main() {
	a, b := byValue(), byPointer()
	fmt.Println(a.X, b.Y, sumLocal(), len(dynamic(3)))
}
$ go build -gcflags=-m escape.go
# command-line-arguments
./escape.go:7:6: can inline byValue
./escape.go:9:6: can inline byPointer
./escape.go:11:6: can inline sumLocal
./escape.go:19:6: can inline dynamic
./escape.go:22:17: inlining call to byValue
./escape.go:22:30: inlining call to byPointer
./escape.go:23:32: inlining call to sumLocal
./escape.go:23:47: inlining call to dynamic
./escape.go:23:13: inlining call to fmt.Println
./escape.go:9:34: &Point{...} escapes to heap
./escape.go:12:14: make([]int, 8) does not escape
./escape.go:19:40: make([]int, n) escapes to heap
./escape.go:22:30: &Point{...} does not escape
./escape.go:23:13: ... argument does not escape
./escape.go:23:15: a.X escapes to heap
./escape.go:23:20: b.Y escapes to heap
./escape.go:23:32: ~r0 escapes to heap
./escape.go:23:32: make([]int, 8) does not escape
./escape.go:23:39: len(~r0) escapes to heap
./escape.go:23:47: make([]int, n) escapes to heap

Read the lines against the code. Inside byPointer, &Point{3, 4} escapes to heap because the pointer outlives the function — yet at line 22, where main calls it, the same expression does not escape: the small functions were inlined into main first (the can inline and inlining call lines), and inside main the pointer never leaves. make([]int, 8) with a constant size stays on the stack, while make([]int, n) with a size known only at run time escapes. The a.X escapes to heap lines are fmt.Println boxing each argument into an interface, a common hidden source of allocations in hot loops.

Counting allocations

testing.AllocsPerRun measures heap allocations per call, and the number is the same on every run.

package main

import (
	"fmt"
	"strconv"
	"testing"
)

type Point struct{ X, Y int }

var sink any

func main() {
	fmt.Println("value:     ", testing.AllocsPerRun(1000, func() {
		p := Point{1, 2}
		_ = p.X + p.Y
	}))
	fmt.Println("to any:    ", testing.AllocsPerRun(1000, func() {
		sink = Point{1, 2} // boxing into an interface
	}))
	fmt.Println("Itoa:      ", testing.AllocsPerRun(1000, func() {
		_ = strconv.Itoa(123456)
	}))
	fmt.Println("Sprint:    ", testing.AllocsPerRun(1000, func() {
		_ = fmt.Sprint(123456)
	}))
	fmt.Println("small Itoa:", testing.AllocsPerRun(1000, func() {
		_ = strconv.Itoa(7)
	}))
}
$ go run allocs.go
value:      0
to any:     1
Itoa:       1
Sprint:     1
small Itoa: 0

A struct used locally costs nothing. Storing it in an any copies it to the heap. strconv.Itoa and fmt.Sprint each cost exactly one allocation here, the result string: boxing a constant argument into an interface{} doesn’t allocate, so Sprint pays nothing extra for it. Itoa(7) allocates nothing: the runtime keeps preallocated strings for small numbers. This kind of check is how you confirm an optimisation actually removed an allocation.

Watching the collector

Go’s collector is concurrent and non-moving. Since Go 1.26 the default is the “Green Tea” collector, which scans small objects faster. This section was re-run on Go 1.27.2 (linux/amd64, 12 CPUs). GODEBUG=gctrace=1 prints one line per collection.

package main

import (
	"fmt"
	"runtime"
)

var keep [][]byte

func main() {
	for i := range 200 {
		buf := make([]byte, 1<<20) // 1 MB of garbage per iteration
		if i%50 == 0 {
			keep = append(keep, buf) // keep 4 of them alive
		}
	}
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	fmt.Printf("collections: %d, live heap: %d MB, total allocated: %d MB\n",
		m.NumGC, m.HeapAlloc>>20, m.TotalAlloc>>20)
}
$ go build -o gctrace gctrace.go
$ GODEBUG=gctrace=1 ./gctrace 2>&1 | tail -4
gc 47 @0.023s 13%: 0.038+0.12+0.004 ms clock, 0.45+0/0.092/0.025+0.052 ms cpu, 11->11->5 MB, 11 MB goal, 0 MB stacks, 0 MB globals, 12 P
gc 48 @0.024s 13%: 0.049+0.081+0.003 ms clock, 0.59+0/0.098/0+0.047 ms cpu, 11->11->5 MB, 11 MB goal, 0 MB stacks, 0 MB globals, 12 P
gc 49 @0.024s 13%: 0.040+0.10+0.003 ms clock, 0.48+0/0.094/0.007+0.043 ms cpu, 11->11->5 MB, 11 MB goal, 0 MB stacks, 0 MB globals, 12 P
collections: 49, live heap: 5 MB, total allocated: 200 MB

The binary is built first so the trace covers only the program, not the compiler. Each gc N line shows when it ran, the share of CPU spent on GC so far, the wall-clock and CPU time of its phases, then three heap sizes — at the start of the collection, at the end, and the live data that survived (11->11->5 MB) — the heap goal for the next cycle, and the number of Ps (12 P, one per CPU the program may use). The program allocated about 200 MB in total but usually had only a few MB live, so the collector ran repeatedly and cheaply. The count changes from run to run: four runs gave 37 to 56 collections. Some runs end with a spike such as 11->66->59 MB, when the program allocated faster than a concurrent cycle could finish. TotalAlloc counts every byte ever allocated; HeapAlloc counts what is live (or not yet collected).

GOGC trades memory for CPU. The default, 100, lets the heap grow 100% over the live data before collecting:

$ GOGC=50 ./gctrace
collections: 93, live heap: 6 MB, total allocated: 200 MB
$ GOGC=100 ./gctrace
collections: 32, live heap: 12 MB, total allocated: 200 MB
$ GOGC=400 ./gctrace
collections: 7, live heap: 49 MB, total allocated: 200 MB

A lower GOGC means more collections; quadrupling it cut them sharply, and the heap grew larger between them. The exact counts change a lot between runs. Over five rounds, GOGC=50 gave 70 to 93 collections, GOGC=100 gave 32 to 50, and GOGC=400 gave 3 to 7. The live heap figure is noisier still, because it depends on where in a cycle the program stops. A build with the old collector (GOEXPERIMENT=nogreenteagc) gave counts in about the same ranges, because this program’s 1 MB buffers are large objects and Green Tea speeds up scanning small ones. GOMEMLIMIT=512MiB adds a soft ceiling on total memory, which is the safer knob in containers with a hard memory limit.

A leak through a subslice

The collector frees only unreachable memory. A tiny slice still pointing into a huge array keeps the whole array alive.

package main

import (
	"fmt"
	"runtime"
	"slices"
)

func header(clone bool) []byte {
	file := make([]byte, 64<<20) // pretend we read a 64 MB file
	if clone {
		return slices.Clone(file[:16])
	}
	return file[:16]
}

func liveMB() uint64 {
	runtime.GC()
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	return m.HeapAlloc >> 20
}

func main() {
	h1 := header(false)
	fmt.Println("subslice kept: ", liveMB(), "MB live")
	runtime.KeepAlive(h1)
	h1 = nil

	h2 := header(true)
	fmt.Println("clone kept:    ", liveMB(), "MB live")
	runtime.KeepAlive(h2)
}
$ go run leak.go
subslice kept:  64 MB live
clone kept:     0 MB live

Both functions return 16 bytes, but the subslice’s backing array is the full 64 MB, and it stays reachable as long as h1 is. slices.Clone copies the 16 bytes into a new array, so the big one becomes garbage. The same happens with substrings of a large string; strings.Clone is the fix there. runtime.KeepAlive just keeps each slice reachable until after the measurement.

Benchmarks: preallocation and sync.Pool

-benchmem reports bytes and allocations per operation alongside time.

package mem

import (
	"bytes"
	"sync"
	"testing"
)

func BenchmarkAppendGrow(b *testing.B) {
	for b.Loop() {
		var s []int
		for i := range 1000 {
			s = append(s, i)
		}
	}
}

func BenchmarkAppendPrealloc(b *testing.B) {
	for b.Loop() {
		s := make([]int, 0, 1000)
		for i := range 1000 {
			s = append(s, i)
		}
	}
}

var pool = sync.Pool{New: func() any { return new(bytes.Buffer) }}

func BenchmarkBufferNew(b *testing.B) {
	for b.Loop() {
		buf := new(bytes.Buffer)
		buf.Grow(4096)
		buf.WriteString("payload")
	}
}

func BenchmarkBufferPool(b *testing.B) {
	for b.Loop() {
		buf := pool.Get().(*bytes.Buffer)
		buf.Reset()
		buf.Grow(4096)
		buf.WriteString("payload")
		pool.Put(buf)
	}
}
$ go test -bench=. -benchmem
goos: linux
goarch: amd64
pkg: example.com/bench
cpu: Intel(R) Xeon(R) Processor @ 2.10GHz
BenchmarkAppendGrow-4       	  122048	      9335 ns/op	   25208 B/op	      12 allocs/op
BenchmarkAppendPrealloc-4   	 2239371	       498.3 ns/op	       0 B/op	       0 allocs/op
BenchmarkBufferNew-4        	  728980	      1674 ns/op	    4096 B/op	       1 allocs/op
BenchmarkBufferPool-4       	64697486	        16.55 ns/op	       0 B/op	       0 allocs/op
PASS
ok  	example.com/bench	4.554s

Growing a slice to 1,000 elements allocated 12 times as it grew, copying the elements each time. The preallocated version didn’t touch the heap at all: its capacity is a constant and the slice never escapes, so escape analysis put it on the stack — the biggest win in the table. BufferNew’s one allocation is the 4 KB from Grow. The pooled buffer allocated nothing per operation, because each Get returned the buffer the previous iteration Put back. Reset pooled objects before use, never keep a reference after Put, and remember the pool may be emptied at any collection — it is a cache, not storage.

Finding allocations with pprof

A memory profile records where allocations happen. go test can write one from a benchmark:

$ go test -bench=AppendGrow -benchmem -memprofile=mem.out >/dev/null
$ go tool pprof -sample_index=alloc_space -top mem.out 2>/dev/null | head -8
File: bench.test
Build ID: e55b9f99864cb9e9a5528b8134cc137165c515be
Type: alloc_space
Time: 2026-10-06 18:45:08 UTC
Showing nodes accounting for 2.61GB, 99.94% of 2.61GB total
Dropped 20 nodes (cum <= 0.01GB)
      flat  flat%   sum%        cum   cum%
    2.61GB 99.94% 99.94%     2.61GB 99.94%  example.com/bench.BenchmarkAppendGrow

alloc_space ranks functions by total bytes allocated; -sample_index=inuse_space would show what is still live instead. In a running service, import net/http/pprof and fetch /debug/pprof/heap with the same go tool pprof command; -http=:8081 opens an interactive flame graph instead of the text table.

Practice

1. Use -gcflags=-m on a function func newBuf() *[64]byte { var b [64]byte; return &b }. Then change it to return the array by value and check again.

Solution
package main

import "fmt"

func newBufPtr() *[64]byte {
	var b [64]byte
	return &b
}

func newBufVal() [64]byte {
	var b [64]byte
	return b
}

func main() {
	p, v := newBufPtr(), newBufVal()
	fmt.Println(len(p), len(v))
}
$ go build -gcflags=-m p1.go 2>&1 | grep -v inlin
# command-line-arguments
./p1.go:6:6: moved to heap: b
./p1.go:17:13: ... argument does not escape
./p1.go:17:17: 64 escapes to heap
./p1.go:17:25: 64 escapes to heap

Only the pointer version moves b to the heap (moved to heap: b is how -m reports an escaping local variable). Returning 64 bytes by value is a cheap copy and leaves nothing for the collector.

2. Measure with testing.AllocsPerRun how many allocations it takes to build a 100-entry map[int]int with and without a size hint.

Solution
package main

import (
	"fmt"
	"testing"
)

func fill(m map[int]int) {
	for i := range 100 {
		m[i] = i
	}
}

func main() {
	fmt.Println("no hint:", testing.AllocsPerRun(100, func() { fill(map[int]int{}) }))
	fmt.Println("hint:   ", testing.AllocsPerRun(100, func() { fill(make(map[int]int, 100)) }))
}
$ go run p2.go
no hint: 9
hint:    3

Without a hint the map grows in steps, allocating new storage each time; with one it allocates its storage up front.

3. Rewrite header in leak.go for strings: read a large string, return its first 16 bytes with strings.Clone, and measure the live heap.

Solution
package main

import (
	"fmt"
	"runtime"
	"strings"
)

func firstLine(clone bool) string {
	big := strings.Repeat("x", 32<<20) // 32 MB
	if clone {
		return strings.Clone(big[:16])
	}
	return big[:16]
}

func liveMB() uint64 {
	runtime.GC()
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	return m.HeapAlloc >> 20
}

func main() {
	a := firstLine(false)
	fmt.Println("substring:", liveMB(), "MB")
	runtime.KeepAlive(a)
	a = ""
	b := firstLine(true)
	fmt.Println("clone:    ", liveMB(), "MB")
	runtime.KeepAlive(b)
}
$ go run p3.go
substring: 32 MB
clone:     0 MB

A substring shares its parent’s bytes exactly like a subslice does, so it pins all 32 MB until it’s cloned.

Frequently Asked Questions

Does Go have manual memory management?
No. You allocate with make, new, composite literals or by taking an address, and a concurrent garbage collector frees memory once nothing references it. You influence memory use by allocating less and by tuning GOGC and GOMEMLIMIT, not by freeing objects yourself.
What is escape analysis?
The compiler decides for each value whether it can live on the goroutine's stack, which is freed automatically when the function returns, or must escape to the heap, which the garbage collector manages. Returning a pointer to a local or storing it in an interface usually forces an escape. go build -gcflags=-m prints each decision.
What is sync.Pool used for?
sync.Pool caches temporary objects, such as byte buffers, so hot code can reuse them instead of allocating each time. Pooled objects can be dropped at any garbage collection, so a Pool is for reducing allocation, never for holding state you need to get back.
What do GOGC and GOMEMLIMIT control?
GOGC sets how much the heap may grow, as a percentage of the live heap after the last collection, before the next collection starts; the default is 100. GOMEMLIMIT sets a soft limit on the runtime's total memory, making the collector work harder as usage approaches it.
How can a Go program leak memory with a garbage collector?
By keeping references it no longer needs: a small subslice that pins a huge backing array, a map that only grows, a global cache with no eviction, or goroutines blocked forever with their stacks. The collector can only free what is unreachable, so the fix is to drop or copy the reference.