Stack vs Heap in Go and Who Decides Where Values Live
The Go compiler decides whether a value lives on the stack or the heap using escape analysis. See what escapes, the compiler report and allocation counts.

Programmers from C ask where a Go variable lives, and the honest answer is that you do not get to say. The compiler analyzes each function, decides whether the address of a value can outlive the call, and puts the value on the stack when it cannot. The stack is cheap, nothing to free and no work for the collector. The heap is where everything else goes, and it costs an allocation now and garbage collection later. The distinction matters because it is invisible in the source and visible in the profile. This article shows the compiler's reasoning with its own output, runs the cases that surprise people and ends with how much to care. It follows pointers in Go and returning a pointer or a value, and it explains the allocation counts in the garbage collector article. It is part of the Go basics path.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
Nobody declares it
Go has no stack and heap keywords and no malloc. new(T) and &T{} do not mean "heap": in a function where the pointer does not leave, they are stack values. A local var p Point is not necessarily on the stack. Whether it is depends on what the rest of the function does with its address. The Go FAQ says it plainly: you do not need to know, since correctness does not depend on it, and the compiler is free to choose. That freedom is why a safe pointer to a local variable is allowed to be returned from a function, which would be a dangling pointer in C.
The compiler's report
Ask the compiler what it decided. This file has four functions that look similar:
package main
type Point struct{ X, Y int }
var global *Point
var sinkAny any
func sum() int {
p := Point{1, 2}
q := &p
return q.X + q.Y
}
func leak() *Point {
p := Point{3, 4}
return &p
}
func store() {
p := Point{5, 6}
global = &p
}
func boxed(n int) {
sinkAny = n + 1000
}
func main() {
sum()
global = leak()
store()
boxed(5)
}# example.test/lab
example.test/lab/main.go:8:6: can inline sum
example.test/lab/main.go:14:6: can inline leak
example.test/lab/main.go:19:6: can inline store
example.test/lab/main.go:24:6: can inline boxed
example.test/lab/main.go:28:6: can inline main
example.test/lab/main.go:29:5: inlining call to sum
example.test/lab/main.go:30:15: inlining call to leak
example.test/lab/main.go:31:7: inlining call to store
example.test/lab/main.go:32:7: inlining call to boxed
example.test/lab/main.go:15:2: moved to heap: p
example.test/lab/main.go:20:2: moved to heap: p
example.test/lab/main.go:25:14: n + 1000 escapes to heap
example.test/lab/main.go:30:15: moved to heap: p
example.test/lab/main.go:31:7: moved to heap: p
example.test/lab/main.go:32:7: n + 1000 escapes to heapIn the report, the lines to read are those with moved to heap and escapes to heap. leak and store each have moved to heap: p, because the address of p left the function, by return in the first case and into a package variable in the second. boxed has n + 1000 escapes to heap: converting an integer to an interface needs a place for the value to live, and sinkAny is long lived. sum does not appear, because q is only used inside the function, so p stays on the stack even though the code takes its address. That is the answer to "does & allocate": only when the pointer escapes.
The allocation counts
The compiler output tells you what it decided, and testing.AllocsPerRun shows the effect at run time. Eight small functions, called repeatedly, with the allocations per call:
package main
import (
"fmt"
"testing"
)
type Point struct{ X, Y int }
var global *Point
var sinkAny any
//go:noinline
func sum() int {
p := Point{1, 2}
q := &p
return q.X + q.Y
}
//go:noinline
func leak() *Point {
p := Point{3, 4}
return &p
}
//go:noinline
func store() {
p := Point{5, 6}
global = &p
}
//go:noinline
func boxed(n int) {
sinkAny = n + 1000
}
//go:noinline
func small() int {
buf := make([]byte, 1024)
buf[0] = 1
return int(buf[0])
}
//go:noinline
func large() int {
buf := make([]byte, 1<<20)
buf[0] = 1
return int(buf[0])
}
//go:noinline
func sized(n int) int {
buf := make([]byte, n)
buf[0] = 1
return int(buf[0])
}
func main() {
fmt.Println("take address, keep local:", testing.AllocsPerRun(100, func() { sum() }))
fmt.Println("return pointer:", testing.AllocsPerRun(100, func() { global = leak() }))
fmt.Println("store in global:", testing.AllocsPerRun(100, func() { store() }))
fmt.Println("box an int:", testing.AllocsPerRun(100, func() { boxed(5) }))
fmt.Println("1 KB constant slice:", testing.AllocsPerRun(100, func() { small() }))
fmt.Println("1 MB constant slice:", testing.AllocsPerRun(100, func() { large() }))
fmt.Println("16 byte variable slice:", testing.AllocsPerRun(100, func() { sized(16) }))
fmt.Println("4096 byte variable slice:", testing.AllocsPerRun(100, func() { sized(4096) }))
}take address, keep local: 0
return pointer: 1
store in global: 1
box an int: 1
1 KB constant slice: 0
1 MB constant slice: 1
16 byte variable slice: 0
4096 byte variable slice: 1The first row is zero, as the compiler report predicted. The next three each allocated once, matching moved to heap and escapes to heap. The slice rows teach the size rules. A local make([]byte, 1024) with a constant size stays on the stack and allocated nothing, while a 1 MB one is too large for a stack frame and goes to the heap, as the compiler's report for that function says: make([]byte, 1048576) escapes to heap. Implicit allocations such as make and new move to the heap above roughly 64 KB, and an explicitly declared array variable can stay on the stack up to a larger limit, in the megabytes, so the exact threshold depends on how the value is created. The last two are the Go 1.25 behavior: a slice whose size is only known at run time used to always escape, and since Go 1.25 the compiler can use a small stack buffer for small sizes. The 16 byte slice allocated nothing, and the 4,096 byte one went to the heap. That optimization is why a make in a hot function sometimes shows zero allocations, and why a benchmark result can change when you upgrade.
What makes a value escape
The common causes, in the order I meet them:
| Cause | Example | Why |
|---|---|---|
| Returning a pointer to a local | return &p | The caller needs it after the frame is gone |
| Storing it somewhere longer-lived | global = &p, a field of a struct that escapes | The container outlives the call |
| Passing to a goroutine | go f(&p) | The goroutine outlives the call |
| Converting to an interface | fmt.Println(n), any(n) | The interface holds a pointer to the data |
| Capturing in a closure that escapes | return func() { use(p) } | The closure keeps the variable |
| Unknown size or too big | make([]T, n) for large n | The stack frame cannot size it |
| Calls through an interface or a function value | r.Read(buf) on an io.Reader | The compiler cannot see the callee, so it assumes the worst |
The last row is the least obvious and the most common in practice. When a function calls through an interface, the compiler cannot prove the callee does not keep the argument, so arguments that are pointers escape. That is why fmt.Fprintf(w, ...) allocates, and why an interface in a hot loop shows up in allocation profiles, as interfaces measured.
Goroutine stacks
Each goroutine has its own stack, which starts small and grows as needed by allocating a larger one and copying. That is what makes a goroutine cheap and a deep recursion possible, and it is also why a stack variable's address is not stable, since the runtime moves stacks and updates the pointers into them. The practical consequence is that you can start hundreds of thousands of goroutines, and a program that recurses a million frames deep works, up to the 1 GB limit shown in the functions guide. Stack growth has a cost, a copy, which a goroutine that repeatedly grows and shrinks pays each time.
How much to care
For most code, none. The compiler is good at this, the allocator is fast and the collector handles typical loads without you noticing. Care when a profile points at it: a heap profile with a function near the top, an allocs/op number in a benchmark that you can explain and reduce. The fixes are the ones the table suggests: return values and not pointers for small types, pre-size slices, pass the buffer in, keep a pointer local, and avoid converting to any in an inner loop. Do not hand-optimize by guessing: a change that makes a value stay on the stack may make the code less clear for no measurable gain. When you do change something, rerun -gcflags=-m and the benchmark, since the compiler's answer is the only reliable one and it changes between releases. The compiler's own description is in the Go FAQ entry on stack or heap, and the flags of the -m output are documented under go build's gcflags.


