Go · 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.