Stack, heap and escape analysis: why a pointer means nothing yet
Where a value lives is decided in Go neither by you nor by the presence of &— it is decided by the compiler, and it asks one question: will the value outlive its frame. Measured: a pointer that never leaves gives zero allocations, while a struct with no pointer at all gives one; escaping costs ×12.8, and the price grows with the size of the value.
Full technical treatment
TL;DR
A value has two possible homes: the stack and the heap. The stack is memory tied to function calls and lives exactly as long as the call does; the heap is memory that can outlive the call. In Go you do not choose between them — the compiler does, and it asks a single question: will the value outlive its call? If that cannot be proved, the value goes to the heap.
Hence the main consequence: you cannot tell the answer by looking at the
code. Measured: an address taken and never passed out — 0 allocations, and
the compiler prints exactly that: does not escape. And the other way round: a
struct with no & at all, stored into an interface — 1 allocation, because
an interface holds an address. Both familiar rules — "a pointer means the heap"
and "no pointer means the stack" — are wrong, and wrong in opposite directions.
Beyond that: numbers, flags and boundaries. The same call through a pointer
is decided differently: the callee only reads — 0, it keeps the pointer — 1. A
size known only at run time means the heap: make([]byte, 64) gives 0
allocations, make([]byte, n) gives 1, even when the slice never leaves. fmt
allocates because its arguments are ...any, but not for the numbers 0–255: the
runtime keeps a ready-made array of those values. The cost of escaping grows
with the size of the value: ×8.6 at sixteen bytes and ×33.5 at two hundred and
fifty-six — work, not a surcharge. And the compiler prints its decisions itself
(go build -gcflags='-m'); they depend on its version, so the output worth
reading is your own rather than a memorised list of cases.
- a function creates local variables, and usually they are only needed while it runs;
- you can take the address of a value and pass it on — into another function, or out of this one;
- a compiler builds the program before it runs, and makes some decisions about it on its own.
- what escape analysis is, what a function's frame is, and the
-gcflags='-m'flag; - how an interface is built internally, what happens to a variable captured by a closure, what
sync.Poolis for, and what inlining is.
What is really being asked
The question almost always arrives as "what goes on the stack and what goes on
the heap", and almost always expects the wrong answer — the one carried over
from C: "locals on the stack, new on the heap".
In Go that distinction does not exist at the language level. The
specification describes &x without a word about where x lives; the FAQ
answers the question by refusing it:
you don't need to know. Each variable in Go exists as long as there are
references to it. The storage location chosen by the implementation is
irrelevant to the semantics of the language.
This is not evasion but the answer itself: returning a pointer to a local variable is legal in Go, and that is precisely why placement is left to the compiler.
Then comes the second half of the same paragraph — and that one is a rule:
if the compiler cannot prove that the variable is not referenced after the
function returns, then the compiler must allocate the variable on the
garbage-collected heap.
Three phrases in it do all the work. "Cannot prove" means the heap is the default and the stack has to be earned. "After the function returns" means the question is about lifetime, not syntax. And "must" means this is a correctness requirement, not an optimisation.
Base: the two places a value can live
Before taking the analysis apart it is worth naming those two places in ordinary words — without them the rest of the discussion has nothing to stand on.
The stack is memory tied to function calls. When a function is called, a piece of memory — a frame — is set aside for its local variables; when it returns, the frame disappears whole, along with everything that was in it. Memory on the stack lives exactly as long as the call does, and nothing has to clean it up: the return itself frees it.
The heap is memory that can outlive the call. A value placed there stays reachable after the function that created it has returned. You pay for that twice: with the allocation when it is created, and with the collector's work later.
And here is the point the whole lesson is built on: in Go you do not choose
between those two places directly. The language has no new versus the stack
and no separate way to "put this on the heap"; the specification says nothing
about placement at all. The compiler chooses, and it chooses by one question:
can this value still be needed once the call has finished?
Two examples where that is visible without a single term.
The first. A function builds a struct, reads a field out of it and returns a number. After the return there is no way to reach the struct: no reference to it is left anywhere. The compiler sees that and leaves it in the frame: zero heap allocations.
The second. The same struct, except the function hands its address outward. Now the struct is referenced after the return, so it must outlive the frame — and the compiler places it on the heap: one allocation.
The difference between the examples is not that the second one "has a pointer in it". The difference is that in the second the value is needed after the return and in the first it is not. That is the only criterion, and it works both ways: an address that is taken and goes nowhere does not send anything to the heap.
That is already enough to answer the basic interview question. Everything below is about there being more ways to "be needed after the return" than it seems: a value goes to the heap through an interface, through a closure, through the body of somebody else's function — and even simply because of a length that is not known at build time.
Mechanism 1: a reference escapes
The base named the criterion in words. Now the same idea as a model you can work your own code out with: it is about the flow of references, not about syntax.
The compiler tracks where a reference to a value can end up and asks one question about each path: will it outlive the frame?
Not one branch of that tree asks about &. They all ask one thing: will the
reference be reachable after the return. Which is why "a pointer" and "the heap"
are independent things.
And hence the asymmetry the FAQ names: the compiler must prove the reference does NOT outlive the frame. Failing to prove it means the heap. The heap is the default; the stack has to be earned.
Mechanism 2: seven cases and the decision for each
Click through the rows and watch where the decision parts ways with the familiar rule:
The allocation counts are printed by a run of bench/goescape/internals.go:
| allocations | |
|---|---|
| a local struct with no pointer | 0 |
| a pointer to a local that never leaves the frame | 0 |
| a pointer passed to a function that reads | 0 |
| a pointer passed to a function that keeps it | 1 |
| a value stored into an interface | 1 |
| a closure called on the spot | 0 |
| a closure stored outward | 1 |
The second row breaks the rule "a pointer means the heap". The fifth breaks the
opposite one: there is no & in the code at all, and yet there is an
allocation.
The last pair is about closures, and it works exactly like the third and fourth.
Capturing decides nothing on its own: while the closure is called on the
spot, what it captured stays in the frame. The moment the closure leaves —
stored, returned or handed to go — everything it captured must outlive the
frame.
Hence the practical point worth naming: go func() { ... }() inside a loop sends
everything it captured to the heap, and that is one of the most commonly
unnoticed allocations in concurrent code.
And the third and fourth are the most substantial pair. The call looks the
same: &s is passed in both cases. What differs is the body of the callee —
and the decision changes. So escaping is not a property of the call; it is a
property of the whole reachability of the value, computed across function
boundaries.
The compiler is willing to name its decision itself.
bench/goescape/decisions.sh asks it to, with -gcflags='-m -l':
./practice.go:44:7: &Point{...} does not escape
./practice.go:50:10: &Point{...} escapes to heap
./practice.go:57:10: p escapes to heap
The first line is that very case: the address was taken and the value stayed in the frame. This, rather than guessing from the code, is how to get the answer for your own function.
And the whole table has a boundary worth stating outright: these are the
decisions of the current compiler. Escape analysis lives in the optimiser, not
in the language — the specification says nothing about placement, and the
optimiser gets smarter from version to version. So a line that escapes today may
stop escaping in the next release, and the other way round; it will change
silently, without a single build error. What is worth remembering is therefore
not the list of cases but how to ask: -gcflags='-m' on your own code and on the
Go version you build with.
Mechanism 3: why fmt adds allocations — and when it does not
The most common uninvited heap traffic in real code comes not from pointers but from logging:
| allocations | |
|---|---|
| the same variable, not printed | 0 |
fmt.Fprintln(io.Discard, 42) | 0 |
fmt.Fprintln(io.Discard, 100000) | 1 |
boxing 255 into an interface | 0 |
boxing 256 into an interface | 1 |
fmt.Sprintf("%v", aStruct) | 2 |
The cause of the allocation is not the formatting but the signature: fmt
declares its arguments as ...any. Boxing a value into an interface needs an
address, and the address is passed into the callee and outlives the frame.
So why does 42 not allocate? The first version of this measurement printed only 42, got a zero — and thereby contradicted its own text. The cause turned out not to be escape analysis at all: the runtime keeps a ready-made array of the values 0–255, and boxing a small number takes an address from there, allocating nothing. At 256 the cache runs out and the allocation appears.
Two practical conclusions follow, and neither is about banning logs. First: debug printing in a hot loop changes not only the timing but the memory behaviour of the code being measured — a profile taken alongside it describes a different program. Second, less obvious: a measurement on small numbers may not show this at all, and the conclusion "logging is free" will be reached honestly and be wrong.
Mechanism 4: a size known only at run time
| allocations | |
|---|---|
make([]byte, 64) — a constant | 0 |
make([]byte, n) — n known at run time | 1 |
make([]byte, 64) with the slice kept | 1 |
The second row follows from none of the familiar rules. The slice never leaves — and it is still the heap.
The reason is simple and rarely spelled out: the compiler must lay out the frame in advance, at build time. A frame is a fixed piece of stack whose size is baked into the function's code. An unknown length cannot be reserved in advance.
The practical conclusion is narrow and therefore useful: on a hot path a
constant buffer length is not pedantry but the difference between the stack and
the heap. var buf [64]byte and buf := make([]byte, n) with n == 64
produce different machine code.
Deeper: what it costs — and what is missing from that price
The same struct, built in a function that returns a number, against the same one in a function that returns a pointer:
| time | |
|---|---|
| the struct stays on the stack | 1.94 ns |
| the same struct escapes to the heap | 24.84 ns |
| ×12.8 |
And the second half, which explains why memorising a single ratio is useless:
| size | stack | heap | ratio |
|---|---|---|---|
| 16 bytes | 2.66 ns | 22.94 ns | ×8.6 |
| 256 bytes | 2.37 ns | 79.34 ns | ×33.5 |
An earlier recording of this measurement gave ×25.9 — twice as much. It was taken while the site build was running on the same machine. A heap allocation suffers from competition for memory more than stack work does, so the ratio inflates: a number taken on a busy machine measures the machine, not the language. The ratio here is not worth memorising at all — what is worth remembering is what it depends on.
The stack column barely depends on size. Reserving room in the frame costs the same either way: it is a shift of the stack pointer, and it does not know how many bytes follow. The heap column grows fourfold, because an allocation includes zeroing the whole region.
So escaping is not a fixed surcharge "for a pointer" but work proportional to the size of the value. The first version of this measurement predicted the opposite; the data had to be accepted and the text rewritten.
What is missing from that price
Three things, without which the numbers above pass themselves off as more than they are.
The collector's work. The measurement never lets the heap grow, so it is invisible here — while in a real program what was allocated then has to be marked and swept, and that work grows with the volume of garbage. The numbers above are a lower bound.
The dependence on the workload. Twenty nanoseconds a call mean nothing in a handler that spends ten milliseconds talking to a database, and mean a great deal in a function called ten million times per request. The same allocation is both invisible and decisive.
The fact that the heap is sometimes faster. A value on the heap that outlives many calls is allocated once; the same value "on the stack" inside a loop may be built and torn down every iteration. Escape analysis is about placement, not about speed, and equating "escaped" with "slow" is wrong in exactly the way equating "a pointer" with "the heap" is.
Hence the only order of operations that works: profile first, then
-gcflags='-m'. The profile says whether it is worth looking at all; the flag
says where exactly.
And both are done on your own version of Go. The ratio depends on the machine, and whether there is an allocation at all depends on the compiler: somebody else's number, or somebody else's "this construct does not allocate", cannot be carried over — they are obtained afresh.
How to answer in an interview
The short answer: in Go you do not choose between the stack and the heap — the
compiler chooses, and it asks one question: will the value outlive its frame.
If that cannot be proved, it is the heap. Which is why returning a pointer to a
local variable is legal, and why & in the code says nothing about placement by
itself.
That is enough to answer correctly. Beyond it is what you add when the interviewer digs.
If the interviewer digs deeper
Say that the language draws no such distinction. "Go has no new versus the
stack: the specification does not say where a value lives. The heap is the
default and the stack has to be earned — the compiler has to prove the value does
not outlive the frame."
Refute both familiar rules with numbers. "& means nothing: a pointer to a
local struct gave me zero allocations. And the reverse — a struct with no &,
stored into an interface, gave one."
Name the non-obvious cause. "A make with a length known only at run time
goes to the heap without leaving anywhere: the frame is laid out at build time."
Say how to find out for certain. "go build -gcflags='-m' — the compiler
prints does not escape and escapes to heap line by line. There is nothing to
guess."
And on the cost — with a caveat. "I got ×12.8 on a small struct, but the ratio grows with size: an allocation includes zeroing. Plus the collector's work, which a microbenchmark never shows."
And one thing that is easy to overdo: the list of cases is not a contract. Escape analysis lives in the compiler's optimiser rather than in the specification, and it changes from version to version — what escapes today may not escape tomorrow. The precise wording is this: the decision is read off the compiler you build with, and read again after a version change.
Next they ask
Should a function return a struct or a pointer to it?
The right answer begins by refusing the general rule. Returning a value copies the struct, but the copy lands in the caller's frame — the heap is never touched. Returning a pointer hands the address outward — exactly the case where the value must outlive the frame, so it means an allocation plus the collector's work later.
So for small structs returning by value is usually cheaper, and "a pointer, to avoid the copy" is a false economy. Where the crossover falls depends on the size of the struct and is measured, not guessed: a copy costs linearly in size, and so does an allocation — but with a larger constant and a deferred bill from the collector.
What is sync.Pool and when is it needed?
It is a cache of temporary objects from which an already-allocated instance can be taken instead of a new one. It is needed exactly when escape analysis has lost — the objects are large, short-lived and created often — and only after a profile has shown it.
The important caveat, and the one they check for: a Pool gives no guarantees.
The collector empties it, objects come back uncleaned, and resetting their
contents is your job. It is a tool of last resort, not a way to "speed up
allocations".
Does a closure always send what it captures to the heap?
No, and this is the same lifetime question again. If the closure is called right
here and never stored, the captured variables stay in the frame. If it leaves —
returned, stored in a field, passed to go — everything it captured must outlive
the frame.
Hence the practical point: go func() { ... }() inside a loop sends everything
it captures to the heap, and that is one of the most commonly unnoticed
allocations in concurrent code.
Why is a goroutine's stack 2 KB if frames can be large?
A goroutine's stack grows. It starts at two kilobytes, and when the next call does not fit, the runtime allocates a stack twice the size and copies the old one into it, fixing up the pointers. Which is also why pointers must be known precisely — the runtime has to find them.
The practical consequence: deep recursion in Go does not fail with a stack overflow, it gets expensive — every growth costs a copy. And the converse: a value too large for a frame is sent to the heap by the compiler whether it escapes or not.
How is escape analysis related to inlining?
Directly: inlining happens earlier and changes the input to the analysis. The body of an inlined function ends up in the caller's frame, and a value that used to "escape" into it stops escaping at all.
This is also the main trap of microbenchmarks. A two-line function gets inlined,
the allocation disappears, and the measurement shows a zero where real code has a
one. That is why the measurements in this lesson carry //go:noinline: without
it they would be comparing one thing against itself.
Is it worth restructuring code for the stack?
Only with a profile in hand. Escape analysis is something worth being able to
read once pprof has shown that the program spends its time in allocations; it
is not a criterion for how to write code in the first place.
The reason is not laziness: the techniques against allocations — preallocated
buffers, sync.Pool, returning by value instead of by pointer — almost always
make the code longer and more brittle. Paying that for a gain nobody measured is
a bad trade, and in an interview that answer is worth more than a list of
techniques.
Common misconceptions
an address was taken, so the value is on the heap
The measurement does not support this: a pointer to a local struct that never leaves the frame gives zero allocations, and the compiler prints does not escape for that line. What decides is not & but whether the value outlives the frame.
no pointer means the value is on the stack
Wrong too. A struct with no & at all, stored into an interface, gives one allocation: an interface holds an address, and the address outlives the frame. This is also where fmt's allocations come from — its arguments are ...any.
passing by pointer is cheaper because it avoids the copy
Only if the value escapes anyway. A copy lands in the caller's frame and never touches the heap, while a pointer handed outward means an allocation plus deferred collector work. For small structs returning by value is usually cheaper — and that is measured, not guessed.
returning a pointer to a local variable is dangerous
That is a habit from C. In Go such a return is legal: the compiler sees that the value outlives the frame and places it on the heap. There is no danger; there is a price.
make always allocates on the heap, it is dynamic memory
Measured: make([]byte, 64) that never leaves — 0 allocations. But make([]byte, n) with a length known only at run time gives 1, even when the slice goes nowhere: the frame is laid out at build time, and an unknown length cannot be reserved in advance.
logging does not affect memory, it only writes text
fmt declares its arguments as ...any, and boxing into an interface needs an address. Measured: fmt.Fprintln(io.Discard, 100000) — one allocation. Debug printing in a hot loop changes the memory behaviour of the code being measured, and a profile taken alongside it describes a different program.
boxing a number into an interface always allocates
Not for the values 0–255: the runtime keeps a ready-made array of them, and boxing takes an address from there. Measured: 255 — zero allocations, 256 — one. Because of this, a measurement on small numbers can honestly show "logging is free" and be wrong.
escaping to the heap is a fixed surcharge
It is work proportional to size: an allocation includes zeroing the whole region. Measured: ×8.6 at sixteen bytes and ×33.5 at two hundred and fifty-six, with the stack column almost unchanged. And that is still without the collector's work, which a microbenchmark never shows.
Practice
Two problems. Answer first, then check against the real output: in both, the correct answer comes from a run of the script, not from an assertion.
Practice · predict the output
func local() {
p := Point{1, 2, 3, 4}
sinkN = p.X + p.W
}
func pointerStays() {
p := &Point{1, 2, 3, 4}
sinkN = p.X + p.W
}
func pointerEscapes() {
sinkP = &Point{1, 2, 3, 4}
}
func intoInterface() {
p := Point{1, 2, 3, 4}
sinkI = p
}Practice · estimate
Knowledge check
A function creates a struct, takes its address and reads a field through it, never passing the pointer out. Where does the struct end up?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- A value has two possible homes: the stack and the heap. The stack is memory tied to function calls and lives exactly as long as the call does; the heap is memory that can outlive the call. In Go you do not choose between them — the compiler does, and it asks a single question: will the value outlive its call? If that cannot be proved, the value goes to the heap.
- Hence the main consequence: you cannot tell the answer by looking at the code. Measured: an address taken and never passed out — 0 allocations, and the compiler prints exactly that:
does not escape. And the other way round: a struct with no&at all, stored into an interface — 1 allocation, because an interface holds an address. Both familiar rules — "a pointer means the heap" and "no pointer means the stack" — are wrong, and wrong in opposite directions. - Beyond that: numbers, flags and boundaries. The same call through a pointer is decided differently: the callee only reads — 0, it keeps the pointer — 1. A size known only at run time means the heap:
make([]byte, 64)gives 0 allocations,make([]byte, n)gives 1, even when the slice never leaves.fmtallocates because its arguments are...any, but not for the numbers 0–255: the runtime keeps a ready-made array of those values. The cost of escaping grows with the size of the value: ×8.6 at sixteen bytes and ×33.5 at two hundred and fifty-six — work, not a surcharge. And the compiler prints its decisions itself (go build -gcflags='-m'); they depend on its version, so the output worth reading is your own rather than a memorised list of cases.
In fact
- The measurement does not support this: a pointer to a local struct that never leaves the frame gives zero allocations, and the compiler prints
does not escapefor that line. What decides is not&but whether the value outlives the frame. - Wrong too. A struct with no
&at all, stored into an interface, gives one allocation: an interface holds an address, and the address outlives the frame. This is also wherefmt's allocations come from — its arguments are...any. - Only if the value escapes anyway. A copy lands in the caller's frame and never touches the heap, while a pointer handed outward means an allocation plus deferred collector work. For small structs returning by value is usually cheaper — and that is measured, not guessed.
- That is a habit from C. In Go such a return is legal: the compiler sees that the value outlives the frame and places it on the heap. There is no danger; there is a price.
- Measured:
make([]byte, 64)that never leaves — 0 allocations. Butmake([]byte, n)with a length known only at run time gives 1, even when the slice goes nowhere: the frame is laid out at build time, and an unknown length cannot be reserved in advance. fmtdeclares its arguments as...any, and boxing into an interface needs an address. Measured:fmt.Fprintln(io.Discard, 100000)— one allocation. Debug printing in a hot loop changes the memory behaviour of the code being measured, and a profile taken alongside it describes a different program.- Not for the values 0–255: the runtime keeps a ready-made array of them, and boxing takes an address from there. Measured: 255 — zero allocations, 256 — one. Because of this, a measurement on small numbers can honestly show "logging is free" and be wrong.
- It is work proportional to size: an allocation includes zeroing the whole region. Measured: ×8.6 at sixteen bytes and ×33.5 at two hundred and fifty-six, with the stack column almost unchanged. And that is still without the collector's work, which a microbenchmark never shows.
What is covered
- What is really being asked
- Base: the two places a value can live
- Mechanism 1: a reference escapes
- Mechanism 2: seven cases and the decision for each
- Mechanism 3: why fmt adds allocations — and when it does not
- Mechanism 4: a size known only at run time
- Deeper: what it costs — and what is missing from that price
- How to answer in an interview
- Next they ask
- Common misconceptions
- Practice
- Knowledge check
Sources & further reading
3 SOURCES
- Go FAQ — how do I know whether a variable is allocated on the heap or the stackOfficial documentation. The answer is worth quoting verbatim, because it dissolves the question itself: «you don't need to know. Each variable in Go exists as long as there are references to it. The storage location chosen by the implementation is irrelevant to the semantics of the language.» And, in the same paragraph, the actual rule: «if the compiler cannot prove that the variable is not referenced after the function returns, then the compiler must allocate the variable on the garbage-collected heap.»https://go.dev/doc/faq#stack_or_heap
- The gc compiler — the -m diagnosticOfficial documentation. How to see the decisions yourself: «-m: print optimization decisions». This is the source of the wordings «does not escape» and «escapes to heap» quoted in the lesson.https://pkg.go.dev/cmd/compile
- The Go Programming Language Specification — Address operatorsOfficial documentation. Where the language declines the distinction familiar from C: «For an operand x of type T, the address operation &x generates a pointer of type *T to x.» The specification says nothing about where x lives — and that is a decision, not an omission: placement belongs to the implementation.https://go.dev/ref/spec#Address_operators