Deep Engineering
Advanced·Published·30 MIN

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.

Where to start
Before this lesson it is enough to understand
  • 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.
You do not need to know in advance
  • 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.Pool is 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.

Go FAQ

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.

Go FAQ

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.

language contractThe problem statement and the language guarantee: returning a pointer to a local is legal. The compiler's specific decisions start below and are marked separately.

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

implementation detail · Go 1.24The allocation counts and the wording «does not escape» are output from the current compiler. The model above does not depend on the version; the specific decisions do.

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 pointer0
a pointer to a local that never leaves the frame0
a pointer passed to a function that reads0
a pointer passed to a function that keeps it1
a value stored into an interface1
a closure called on the spot0
a closure stored outward1

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

implementation detail · Go 1.24The small-value cache, and which fmt calls allocate, are how the current runtime is built rather than a guarantee of the language: the cache boundaries can move, and then the numbers below move with them.

The most common uninvited heap traffic in real code comes not from pointers but from logging:

allocations
the same variable, not printed0
fmt.Fprintln(io.Discard, 42)0
fmt.Fprintln(io.Discard, 100000)1
boxing 255 into an interface0
boxing 256 into an interface1
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

implementation detail · Go 1.24The allocation counts are, again, output from the current compiler. The reason (a frame is laid out at build time) is sturdier than the numbers, but it too is about the implementation, not the specification.
allocations
make([]byte, 64) — a constant0
make([]byte, n)n known at run time1
make([]byte, 64) with the slice kept1

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

measured observationbench/goescape, go1.24.7 linux/amd64, two cores. The numbers were taken on one machine and in a microbenchmark that never lets the heap grow: see the caveat at the end of this section.

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 stack1.94 ns
the same struct escapes to the heap24.84 ns
×12.8

And the second half, which explains why memorising a single ratio is useless:

sizestackheapratio
16 bytes2.66 ns22.94 ns×8.6
256 bytes2.37 ns79.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

Next they ask

Should a function return a struct or a pointer to it?

Short answer

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.

Next they ask

What is sync.Pool and when is it needed?

Short answer

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".

Next they ask

Does a closure always send what it captures to the heap?

Short answer

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.

Next they ask

Why is a goroutine's stack 2 KB if frames can be large?

Short answer

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.

Next they ask

How is escape analysis related to inlining?

Short answer

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.

Next they ask

Is it worth restructuring code for the stack?

Short answer

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

Claim

an address was taken, so the value is on the heap

Actually

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.

Claim

no pointer means the value is on the stack

Actually

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.

Claim

passing by pointer is cheaper because it avoids the copy

Actually

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.

Claim

returning a pointer to a local variable is dangerous

Actually

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.

Claim

make always allocates on the heap, it is dynamic memory

Actually

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.

Claim

logging does not affect memory, it only writes text

Actually

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.

Claim

boxing a number into an interface always allocates

Actually

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.

Claim

escaping to the heap is a fixed surcharge

Actually

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

Four functions, each printing its number of heap allocations. In which one does a pointer NOT send the value to the heap — and which one sends it there with no pointer at all?
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

The same four-field struct. How many times dearer is it to build so that it escapes to the heap than so that it stays on the stack?
times

Knowledge check

Question 1 of 6

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?

Sources & further reading

3 SOURCES

  1. 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
  2. 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
  3. 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