Blog

Memory in Go: Stack, Heap, Escape Analysis and the GC

Go decides for you whether a value lives on the stack or the heap. See how escape analysis makes that call, how to watch it with go build -gcflags=-m, how to count allocations, and what the garbage collector does next.

In Go you never write “put this on the heap”. You write ordinary code, and the compiler decides where each value lives. Usually you don’t need to care. When a profile says a hot loop allocates, you need to know how that decision is made and how to see it.

This post covers the stack, the heap, escape analysis, counting allocations, and the garbage collector that cleans up the heap. Every program below was run on Go 1.26, and its output is pasted from the run. Where a claim comes from the compiler, the compiler’s own output is pasted too.

Every call gets a frame on the stack

When a Go function is called, it gets a frame: a block of memory holding its local variables and some bookkeeping. When the function returns, its frame is popped and that memory is reused by the next call. Each goroutine has its own stack of frames.

A goroutine’s stack starts small and grows when it needs to. You can ask the runtime how big new stacks start, and then recurse far deeper than that size allows:

package main

import (
	"fmt"
	"runtime/metrics"
)

func depth(n int) int {
	if n == 0 {
		return 0
	}
	return 1 + depth(n-1)
}

func main() {
	s := []metrics.Sample{{Name: "/gc/stack/starting-size:bytes"}}
	metrics.Read(s)
	fmt.Println("new goroutine stacks start at", s[0].Value.Uint64(), "bytes")
	fmt.Println(depth(1_000_000))
}

It prints:

new goroutine stacks start at 2048 bytes
1000000

A million nested calls can’t fit in 2,048 bytes, so the stack grew. The runtime’s source describes how: when a function needs more stack than is left, the runtime allocates a larger stack, copies the old one across, and carries on. It can shrink stacks again during garbage collection. The 2,048 is what Linux reports on this run. The runtime also adjusts the starting size over time, based on how much stack goroutines have been using, so a long-running program may report a bigger number.

Growth has a ceiling. The documentation for runtime/debug.SetMaxStack says a single goroutine’s stack may grow to 1 GB on 64-bit systems. Infinite recursion hits that limit and crashes with a stack overflow.

The heap holds what outlives the call

Some values must stay alive after the function that made them returns. A frame is gone once its function returns, so those values can’t live there. They go on the heap, a shared area of memory that isn’t tied to any one call. The garbage collector frees heap memory once nothing refers to it.

Explain it like I’m ten

The stack is a pile of notepads on your desk. Every time you start a task, you put a fresh pad on top and scribble on it. When the task is done, you throw that pad away. A task inside a task just adds another pad. It’s fast, and cleanup is free: you only ever throw away the top pad.

The heap is a shared storeroom down the hall. If you make something another person still needs after you’ve finished, you can’t leave it on your notepad, because that’s going in the bin. So you put it in a box in the storeroom and hand them a label saying which shelf it’s on.

Every so often a cleaner walks through the storeroom. Any box that nobody’s label points to gets thrown out.

The precise version

A stack frame is created when a function is called and discarded when it returns. Allocating in a frame costs almost nothing, and nothing has to track it afterwards.

A heap allocation asks the runtime for memory. That memory stays valid for as long as any pointer can reach it. The garbage collector (GC) later finds heap objects that no pointer reaches and reuses their memory. So heap values cost something twice: once to allocate, and again as work for the GC.

Where the analogy breaks: you don’t decide which values go in the storeroom. The compiler does, when it builds your program, and the next section shows how. And the cleaner doesn’t read labels one by one as you drop them. It walks everything reachable from the pads still on desks, and the part about the GC below explains how.

You don’t choose: escape analysis does

Go has no rule that new or & means “heap”. The Go specification doesn’t use the words stack or heap at all. The Go FAQ puts the promise plainly: each variable exists as long as there are references to it. How to keep that promise is up to the compiler.

The compiler keeps the promise with escape analysis. For each value, it asks whether the value can be reached after its function returns. If it can’t prove the answer is no, the value escapes, and the compiler puts it on the heap. Otherwise it’s free to leave it in the frame.

That’s why returning a pointer to a local variable is safe in Go. In C, the same code hands back the address of a stack frame that no longer exists.

package main

import "fmt"

type point struct{ x, y int }

func sum() int {
	p := point{1, 2}
	return p.x + p.y
}

func newPoint() *point {
	p := point{3, 4}
	return &p
}

func five() int {
	n := new(int)
	*n = 5
	return *n
}

func main() {
	fmt.Println(sum(), newPoint().y, five())
}

It prints:

3 4 5

To see the compiler’s decisions, save that as main.go in a module and build it with -gcflags=-m. The extra -l turns off inlining, so each function is analysed exactly as written:

$ go build -gcflags='-m -l' .
# example.com/escape
./main.go:13:2: moved to heap: p
./main.go:18:10: new(int) does not escape
./main.go:24:13: ... argument does not escape
./main.go:24:17: sum() escapes to heap
./main.go:24:31: newPoint().y escapes to heap
./main.go:24:39: five() escapes to heap

Line 13 is p in newPoint. Its address is returned, so it must outlive the call, and the compiler “moved to heap” the variable. Line 18 is the new(int) in five: it “does not escape”, because the pointer never leaves the function. new didn’t force a heap allocation. sum‘s p isn’t mentioned at all, because nothing ever takes its address.

The last four lines are about the call to fmt.Println. They’re explained in the section on interfaces below.

Counting allocations with testing.AllocsPerRun

The -m output tells you what the compiler decided. To check what actually happens when the code runs, count the allocations. testing.AllocsPerRun calls a function many times and returns the average number of heap allocations per call. You can import testing from an ordinary package main:

package main

import (
	"fmt"
	"testing"
)

type point struct{ x, y int }

func newPoint(x, y int) *point {
	return &point{x, y}
}

var saved *point

func main() {
	dropped := testing.AllocsPerRun(1000, func() {
		p := newPoint(3, 4)
		_ = p.x + p.y
	})
	kept := testing.AllocsPerRun(1000, func() {
		saved = newPoint(3, 4)
	})
	fmt.Println("used, then dropped:", dropped)
	fmt.Println("kept in a global:  ", kept)
}

It prints:

used, then dropped: 0
kept in a global:   1

For simple code like this, the count is the same on every run, unlike a timing.

The same function allocated once in one place and not at all in the other. Build it with plain -m, inlining left on, and look for point:

$ go build -gcflags=-m . 2>&1 | grep point
./main.go:11:9: &point{...} escapes to heap
./main.go:18:16: &point{...} does not escape
./main.go:22:19: &point{...} escapes to heap

Line 11 is newPoint on its own, where the pointer is returned and escapes. But newPoint is small, so the compiler inlined it: it copied the function’s body into each caller and ran escape analysis again there. On line 18 the caller only reads p and drops it, so the point stays on the stack. On line 22 the caller stores it in a global, so it escapes.

Whether something escapes depends on the code around it as well as the function itself. That’s why you measure instead of guessing.

Four common reasons a value escapes

Most escapes you’ll meet come from a handful of patterns. Returning a pointer is the first, and you’ve already seen it. Here are the other three.

Storing a value in an interface

fmt.Println takes its arguments as ...any. Putting a value in an interface can make it escape, because the interface may need to hold a pointer to a copy of the value, and the compiler usually can’t see what the callee does with it.

package main

import (
	"fmt"
	"io"
	"testing"
)

func add(total *int, n int) {
	*total += n
}

func show(n int) {
	fmt.Fprintln(io.Discard, n)
}

func main() {
	n, total := 1000, 0

	added := testing.AllocsPerRun(1000, func() {
		n++
		add(&total, n)
	})
	shown := testing.AllocsPerRun(1000, func() {
		n++
		show(n)
	})
	small := testing.AllocsPerRun(1000, func() {
		n++
		show(n % 200)
	})

	fmt.Println("add n to a total:", added)
	fmt.Println("print n:         ", shown)
	fmt.Println("print n % 200:   ", small)
}

It prints:

add n to a total: 0
print n:          1
print n % 200:    0

The compiler agrees with the first two lines:

$ go build -gcflags='-m -l' . 2>&1 | head -4
# example.com/boxing
./main.go:9:10: total does not escape
./main.go:14:14: ... argument does not escape
./main.go:14:27: n escapes to heap

add takes a pointer but never keeps it, so total doesn’t escape. In show, n escapes into the interface, and printing costs one allocation per call.

The third line was the surprise. show(n % 200) goes through the same code, yet it doesn’t allocate. The runtime keeps a ready-made table of the integers 0 to 255 (staticuint64s in runtime/iface.go), and an interface holding one of them points into that table instead of allocating. So “escapes to heap” in the -m output means the compiler couldn’t rule out a heap allocation. It doesn’t promise one happens every time.

A closure capturing a variable

A closure that uses a variable from its enclosing function shares that variable, as the part on functions showed. If the closure outlives the call, the variable has to outlive it too:

package main

import "fmt"

func makeCounter() func() int {
	n := 0
	return func() int {
		n++
		return n
	}
}

func main() {
	next := makeCounter()
	fmt.Println(next(), next(), next())
	next = nil
	fmt.Println(next == nil)
}

It prints:

1 2 3
true
$ go build -gcflags='-m -l' .
# example.com/counter
./main.go:6:2: moved to heap: n
./main.go:7:9: func literal escapes to heap
./main.go:15:13: ... argument does not escape
./main.go:15:18: next() escapes to heap
./main.go:15:26: next() escapes to heap
./main.go:15:34: next() escapes to heap
./main.go:17:13: ... argument does not escape
./main.go:17:19: next == nil escapes to heap

n is moved to the heap, and so is the function value itself (the “func literal” on line 7). The rest are fmt.Println arguments going into interfaces again. Here is what that looks like while the program runs:

stack heap makeCounter n = 0 n = 3 func literal main next next = nil nothing points here now the GC can't reach these: freed main calls makeCounter(): a new frame goes on the stack, with n = 0 the returned func uses n, so n can't stay in the frame: it's on the heap makeCounter returns and its frame pops; next points at the func on the heap next() runs three times, and each call adds one to the n on the heap next = nil: now nothing on the stack points at the func or at n when the GC next runs, it can't reach them, so their memory is freed

makeCounter from the program above. Its frame holds n, but the function it returns uses n, so n lives on the heap next to that function. The frame pops, and main’s next points into the heap. Once next is set to nil, nothing reaches the function or n, and the garbage collector frees them.

Here are those steps in words, in case the animation doesn’t play for you:

  1. main calls makeCounter. A new frame goes on the stack, and it’s where you’d expect n = 0 to live.
  2. The function makeCounter returns uses n, so n must outlive the frame. It lives on the heap, beside the function value.
  3. makeCounter returns and its frame is popped. next in main points at the function on the heap, and the function points at n.
  4. Each of the three calls to next() adds one to that same n, which is now 3.
  5. next = nil. Nothing on the stack points at the function or at n any more.
  6. The next time the GC runs, it can’t reach them, so their memory is freed.

The drawing shows n moving, but nothing actually moves at run time. The compiler made the decision when it built the program, so makeCounter creates n on the heap from its first line. “Moved to heap” is the compiler’s way of saying “this local variable doesn’t get a place in the frame”.

The -l flag matters here too. With inlining on, makeCounter gets inlined into main, and the compiler reports that copy of the closure as not escaping. The analysis in the animation is makeCounter as written.

A slice that’s too big, or of unknown size

A slice’s backing array can sit in the frame when the compiler knows its size and the size is modest. How modest is a compiler setting, not a language rule:

package main

import (
	"fmt"
	"testing"
)

func fixed8192() int {
	buf := make([]int, 8192)
	return len(buf)
}

func fixed8193() int {
	buf := make([]int, 8193)
	return len(buf)
}

func sized(n int) int {
	buf := make([]int, n)
	return len(buf)
}

func main() {
	fmt.Println(testing.AllocsPerRun(100, func() { fixed8192() }))
	fmt.Println(testing.AllocsPerRun(100, func() { fixed8193() }))
	for _, n := range []int{4, 5} {
		fmt.Println(testing.AllocsPerRun(100, func() { sized(n) }))
	}
}

It prints:

0
1
0
1
$ go build -gcflags='-m -l' . 2>&1 | grep make
./main.go:9:13: make([]int, 8192) does not escape
./main.go:14:13: make([]int, 8193) escapes to heap
./main.go:19:13: make([]int, n) does not escape

8,192 ints is 64 KiB, which is exactly the limit on implicit stack variables in the compiler’s source (MaxImplicitStackVarSize in cmd/compile/internal/ir/cfg.go). One more int and the array goes to the heap, even though it never leaves the function.

make([]int, n) was the second surprise. The compiler says it “does not escape”, yet sized(5) allocates. The compiler reserves a small fixed buffer in the frame and checks n at run time. If the slice fits, it uses the buffer. If not, it allocates on the heap. The default buffer is 32 bytes, set in cmd/compile/internal/base/flag.go, which is room for four ints. That’s why 4 fits and 5 doesn’t. These thresholds are compiler details and can change between versions.

The garbage collector: mark, then sweep

The Go garbage collector frees heap memory that nothing can reach. According to the comment at the top of runtime/mgc.go, it’s a concurrent mark and sweep collector. It doesn’t move objects, and it doesn’t split the heap into generations.

A cycle has two main jobs. Marking starts from the roots, which are every goroutine’s stack and the global variables. It follows every pointer and marks each object it reaches. Sweeping then goes through the heap, and any object that wasn’t marked has its memory made available for reuse. Both jobs run alongside your program. The runtime does stop every goroutine at two points, when marking starts and when it finishes.

Go 1.26 turns on a reworked marking implementation, called Green Tea, by default. It changes how the collector walks memory, not the mark-and-sweep idea below.

Explain it like I’m ten

Every box in the storeroom starts with a white sticker, which means “nobody has checked this yet”.

The cleaner starts at the desks. Every box a notepad points to gets a grey sticker: “found, but I haven’t looked inside yet”.

Then the cleaner repeats one move. Pick a grey box, open it, and put a grey sticker on every box its labels point to. Then change that box’s sticker to black: “found, and fully checked”.

When no grey boxes are left, every box that anybody can reach is black. Every box still wearing white is one nobody can get to, so it goes out with the rubbish.

The precise version

That’s tri-colour marking. White objects haven’t been reached, grey objects have been reached but not scanned, and black objects have been scanned. Marking starts by shading the roots grey. The collector takes a grey object, shades everything it points to, and turns it black. When no grey objects remain, the white ones are unreachable, and the sweep phase frees them.

Because your program keeps running during marking, it can change pointers while the collector works. It might store a pointer to a white object inside a black one the collector has already finished with. To stop that object being freed by mistake, the runtime turns on a write barrier during marking: every pointer write also shades the pointers involved. Objects allocated during marking are marked black straight away.

Where the analogy breaks: the cleaner in the story works alone while everyone waits. The real collector runs at the same time as your program, on several threads, and the program itself does some of the marking when it allocates. And there are no stickers on the boxes: the marks are bits the runtime keeps alongside the heap.

Two knobs: GOGC and GOMEMLIMIT

The Go runtime has two settings that control how often the GC runs. Both are environment variables, and both have a function in runtime/debug that changes them while the program runs:

package main

import (
	"fmt"
	"math"
	"runtime/debug"
)

func main() {
	oldPercent := debug.SetGCPercent(100)
	oldLimit := debug.SetMemoryLimit(-1)
	fmt.Println("GOGC:", oldPercent)
	fmt.Println("GOMEMLIMIT is off:", oldLimit == math.MaxInt64)
}

It prints:

GOGC: 100
GOMEMLIMIT is off: true

Each setter returns the previous setting, and a negative memory limit reads the limit without changing it. With neither variable set, the program reports the defaults. Run it with GOGC=50 GOMEMLIMIT=1GiB in the environment and both lines change.

GOGC is a percentage. The runtime documentation says a collection starts when the memory allocated since the last one reaches that percentage of the live data left after it. At the default of 100, the heap can grow to about twice its live size before the next cycle. A higher value means fewer collections and more memory. A lower one means more collections and less memory. GOGC=off turns the collector off.

GOMEMLIMIT is a soft limit on the total memory the Go runtime manages, written in bytes with an optional suffix such as MiB or GiB. As the program nears the limit, the GC runs more often to stay under it. It’s off by default, which is what math.MaxInt64 means. The SetMemoryLimit documentation warns that a limit below what the program really needs can make the GC run almost continuously. It’s a soft limit, not a hard cap.

You can also read memory statistics with runtime.ReadMemStats. Fields like HeapAlloc (bytes on the heap now), TotalAlloc (bytes ever allocated) and NumGC (cycles completed) are useful in a debugging session. There’s no output here, because the numbers change from run to run.

What to do with all this

Most Go code never needs any of this. Here’s the advice that holds up.

Don’t optimise without a profile. An allocation in code that runs once at startup costs you nothing that matters. Measure first, with a benchmark run as go test -bench . -benchmem or with AllocsPerRun as above, and change code only where the numbers show a problem.

Preallocate slices when you know the size. As the part on slices showed, make([]T, 0, n) avoids growing the backing array again and again.

Pass small structs by value. A pointer isn’t automatically cheaper. Copying a small struct is cheap and never makes it escape, while taking its address can, if the compiler can’t prove the pointer stays local. Use a pointer when the function must change the value or the struct is large, as the part on structs explained.

What to remember

  • Each call gets a stack frame, popped when it returns. Goroutine stacks start small and grow by copying.
  • Values that must outlive their function go on the heap, and the garbage collector frees them when nothing reaches them.
  • You don’t choose stack or heap. Escape analysis does, and returning a pointer to a local is safe.
  • go build -gcflags=-m shows the compiler’s decisions, and testing.AllocsPerRun counts what really allocates. Inlining can change the answer.
  • Pointers that are returned or stored, interfaces, closures and large or unknown-size slices are the usual causes of escape.
  • The GC is a concurrent tri-colour mark and sweep. GOGC trades memory for CPU, and GOMEMLIMIT sets a soft ceiling.

Write clear code first, and let a measurement tell you which allocation to remove.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.