skip to content

Memory Allocation and Stacks

Where a Go value lives and who pays for it: escape analysis deciding stack versus heap, goroutine stacks that grow by copying themselves, the size-class allocator behind make and new, and the habits that cut allocations out of a hot path. Interviewers use this to check that "Go is garbage collected" is not the end of your answer.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

explore

questions

16

In Go, is it safe to return a pointer to a local variable from a function?

level: juniorimportance: must knowfreq 72%

answer

  1. the compiler picks the location, not you
  2. Go has no dangling pointers
  3. the address outlives the frame
  4. moved to heap: n

basics

~20 s

Yes. Go has no dangling pointers: at compile time escape analysis sees that the address outlives the function's frame, so the compiler allocates that variable on the heap instead of the stack. The garbage collector frees it later.

solid answer

~40 s

It is always safe. Go's compiler runs escape analysis on every function: it tries to prove that a value's lifetime ends when the frame does, and if it cannot, the value is allocated on the heap instead. So `n := 0; return &n` is legal and correct — the compiler quietly promotes `n` to a heap allocation, and the garbage collector reclaims it once no reference remains. You can see the decision with `go build -gcflags=-m`, which prints `moved to heap: n` for exactly this case. The important consequence is that placement is a compiler decision, not something the syntax controls: `new(T)` and `&T{}` are treated identically, and plenty of pointers never touch the heap at all. Escaping is a performance question — an allocation plus garbage-collector work — never a correctness one.

code

go · 4 lines
go
func newCounter() *int {
	n := 0
	return &n // safe: n is heap-allocated, not a dangling pointer
}

go deeper

for a junior

Be ready to say plainly that returning the address of a local is safe in Go, and that the compiler moves that variable to the heap for you. Knowing the phrase escape analysis and that it happens at compile time is enough here.

for a middle

Explain the mechanics: the stack is the default, a value escapes when the compiler cannot prove its lifetime ends with the frame, and go build -gcflags=-m shows the decision. Be able to list the shapes that cause an escape.

for a senior

Show that you treat escaping as a cost, not a bug: you leave it alone until a benchmark says otherwise, then reshape the API so callers own the memory. Be clear that escape decisions are compiler behaviour, not a specification guarantee.

for a principal

Own the standard your codebase applies: hot paths where allocation counts are tracked deliberately, and everywhere else pointers used because they read best. Push back on style rules that ban pointer returns in the name of performance nobody measured.

## The question behind the question Anyone arriving from C asks this first, because in C returning `&local` hands back a pointer into a frame that has already been popped, and using it is undefined behaviour. Anyone arriving from Java or Python asks the opposite question, because there every object is on the heap and there is nothing to worry about. Go sits between the two, and the answer is: write the code that reads best, and the compiler decides where the memory lives. ## Stack first, heap when it must Every Go function call gets a frame on the calling goroutine's stack. Local variables live there by default, which is the cheapest possible allocation — bumping a pointer, and no cleanup at all, because the whole frame disappears when the function returns. That only works while a value's lifetime is contained by the frame. If a reference to a local can still be reached after the function returns, the frame is the wrong place for it. The compiler detects this case during a pass called **escape analysis**: a value *escapes* when the compiler cannot prove its lifetime ends with the frame. Escaped values are allocated on the heap and are the garbage collector's responsibility. ## The canonical example ```go func newCounter() *int { n := 0 return &n } ``` The address of `n` is returned, so `n` outlives `newCounter`. Escape analysis notices, and `n` is heap-allocated. The frame holds a pointer to it. Callers get a perfectly valid `*int`. Build with `go build -gcflags=-m` and the compiler tells you so, printing `moved to heap: n`. Contrast: ```go func sum(a, b int) int { t := a + b return t } ``` Nothing takes the address of `t`, so `t` lives in a register or the frame and costs nothing. ## Why placement is not visible in the syntax The biggest misconception here is that some spelling forces the heap. It does not: - `new(T)` and `&T{}` are equivalent. Both produce a `*T`, and both are stack-allocated if the pointer does not escape. - Passing a pointer into a function does **not** force a heap allocation. If the callee only reads through the pointer and does not store it anywhere that outlives the call, the pointed-to variable stays in the caller's frame. Escape analysis records, per function, whether a pointer parameter is retained. - Not taking a pointer does not guarantee the stack either. A large local, or a `make` whose size is not a compile-time constant, is heap-allocated because the compiler cannot bound the frame. ## What escaping actually costs The program is correct either way — this is the point worth internalising. What changes is cost: a heap allocation is more expensive than a frame slot, and every heap object adds work for the garbage collector. In ordinary code that is irrelevant. In a function called millions of times a second it is exactly the thing you profile and then try to remove. That is also why the escape decision is not part of the language specification. It is a property of the current compiler, and it improves between releases. Code that relies on a value staying on the stack for *correctness* would be broken code; there is no such dependency in Go, because the semantics are identical either way. ## What typically makes a value escape At this level it is enough to recognise the shapes: - Its address is returned, as above. - Its address is stored somewhere longer-lived: a package-level variable, a field of an already-escaping struct, a slice or map that escapes. - It is captured by a function literal that itself escapes — stored in a slice, returned, or started as a goroutine. - It is passed to a function the compiler cannot see through, so the compiler must assume the worst. - It is too large, or its size is not known at compile time. ## How to check `go build -gcflags=-m ./...` prints one line per decision: `moved to heap: n` for a named variable whose address escapes, `&T{} escapes to heap` for an escaping composite literal, and `p does not escape` for a pointer parameter the callee does not retain. Adding a second `-m` (`-gcflags='-m -m'`) also prints the reasoning that led to each decision. ## Takeaway Return the pointer if the API wants a pointer. Go guarantees it is valid. Then, if and only if a benchmark says the allocation matters, go look at what the compiler decided and whether the API can be shaped to avoid it.

  • Does new(T) allocate on the heap while &T{} allocates on the stack?
    No. They are two spellings of the same thing, and neither decides placement. Escape analysis does: if the resulting pointer does not outlive the frame, either form stays on the stack; if it escapes, either form is heap-allocated. The only difference is that a composite literal can also set field values.
  • If a local escapes to the heap, is the program still correct?
    Yes — escaping is purely a cost question. The semantics are identical: the value lives as long as anything references it, and the garbage collector reclaims it afterwards. What changes is that you paid for an allocation and added an object the collector must trace, which only matters on a hot path.
  • Would returning the value instead of a pointer avoid the heap?
    Usually yes, for a small struct. Returning by value copies into the caller's frame, so nothing needs to outlive the callee. It stops being a win for large structs, where the copy costs more than the allocation, and it is not available when callers must observe mutations through a shared pointer.

It is like a coat check that notices you are leaving with the coat: if the item is walking out of the building, it does not get stored in the cloakroom that closes with the room.

saying these in an interview costs you the question

  • Says the pointer dangles once the function returns
  • Claims new allocates on the heap and & on the stack
  • Thinks the runtime decides placement at call time
  • Believes passing any pointer forces a heap allocation
  • Assumes all local variables in Go are stack-allocated
open as a page

Why does `make([]byte, 0, 4096)` cut allocations, and how does it differ from `make([]byte, 4096)`?

level: juniorimportance: must knowfreq 60%

basics

~20 s

make([]byte, 0, 4096) reserves a 4096-byte array but starts with length 0, so appends fill it without reallocating. make([]byte, 4096) sets the length to 4096, so the slice already holds 4096 zero bytes and append adds data after them.

open as a page

How do Go's mcache, mcentral and mheap cooperate on a small heap allocation?

level: middleimportance: must knowfreq 50%

basics

~20 s

Each P owns an mcache holding one span per size class, so the usual allocation is a lock-free pop. An exhausted span is refilled from that class's shared mcentral, which takes fresh spans from the global mheap.

open as a page

Why does Go's heap allocator use 24 bytes to store a 17-byte object?

level: juniorimportance: should knowfreq 32%

basics

~20 s

Go's heap allocator rounds every small allocation up to one of about 68 fixed size classes, and 24 is the first class that fits 17 bytes. Fixed sizes let one span hold identical objects with no per-object header.

open as a page

How does a goroutine's stack grow when its initial ~2 KB is not enough?

level: juniorimportance: should knowfreq 58%

basics

~20 s

Each Go function starts by checking whether enough stack space is left. If not, it calls into the runtime, which allocates a new stack of double the size, copies the old stack into it, and lets the function continue.

open as a page

In Go's go build -gcflags=-m output, what is the difference between “moved to heap: x” and “x escapes to heap”?

level: middleimportance: should knowfreq 46%

basics

~20 s

“moved to heap: x” names a variable whose address escapes, so the variable itself is now heap-allocated. “x escapes to heap” refers to the value an expression produces — a composite literal, a make, a boxed argument — that must be heap-allocated.

open as a page

In Go, what does `buf = buf[:0]` reuse, and what stays in the underlying array?

level: middleimportance: should knowfreq 45%

basics

~20 s

buf[:0] keeps the same backing array, pointer and capacity and only sets the length to 0, so later appends overwrite from index 0 with no new allocation. The old bytes stay in the array; nothing is zeroed or freed.

open as a page

Why must Go's runtime rewrite pointers when it moves a goroutine's stack?

level: middleimportance: should knowfreq 38%

basics

~20 s

Growing a goroutine's stack copies it to a different address, so every pointer that referred to the old stack would dangle. The runtime uses compiler-generated pointer maps to find those words and add the address delta to each.

open as a page

After a traffic spike, a Go packet server's heap sits at 4 GB with 400 MB live. Why won't it shrink?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Freeing objects does not release memory. Only a span with no live objects at all returns to the page allocator, and Go's collector never moves objects, so one survivor pins its whole 8 KB span.

open as a page

Escape analysis moves a hot-path helper's locals to the heap. How do you remove that allocation without changing behaviour?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Find why the value escapes with go build -gcflags='-m -m', then reshape the code so the lifetime ends with the frame: return values instead of pointers, let the caller own the destination, avoid indirect calls the compiler cannot see through. Confirm with a benchmark.

open as a page

A sync.Pool of output buffers accepts rare 30 MB buffers alongside typical 4 KB ones — why does memory stay high, and what guard fixes it?

level: seniorimportance: should knowfreq 33%

basics

~20 s

sync.Pool has no size limit: it retains whatever you Put until a collection clears it, and it caches per P, so several oversized buffers stay live at once. Guard the release path — drop any buffer whose capacity exceeds a threshold.

open as a page

Your Go query parser dies with 'fatal error: stack overflow' on hostile input — what now?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Deeply nested input drove the recursive-descent parser past the 1 GB per-goroutine stack limit. That is a fatal runtime error, not a panic, so recover cannot save the process. The fix is a depth budget in the parser plus an input size limit.

open as a page

What does Go's tiny allocator do, and which objects qualify for it?

level: middleimportance: nice to knowfreq 20%

basics

~20 s

Go's tiny allocator packs several heap objects that are smaller than 16 bytes and contain no pointers into one shared 16-byte block. It cuts allocation count for things like small ids and counters; anything holding a pointer never qualifies.

open as a page

Why can a Go loop that creates one closure per iteration allocate on every iteration?

level: middleimportance: nice to knowfreq 30%

basics

~20 s

A function literal that captures variables is an object holding a code pointer plus those captured variables. If that function value escapes the frame, each iteration allocates one closure, and each captured variable it refers to is moved to the heap too.

open as a page

When does Go's runtime shrink a goroutine's stack after it has grown?

level: middleimportance: nice to knowfreq 26%

basics

~20 s

Only during garbage collection, when the runtime scans that goroutine's stack. If less than a quarter of the stack is in use, the runtime halves it by copying the live part into a smaller stack, never going below the ~2 KB minimum.

open as a page

What do you require before approving sync.Pool in a shared library other teams import?

level: principalimportance: nice to knowfreq 25%

basics

~20 s

Require evidence the allocation matters, a reset enforced inside the package's own get and put wrappers rather than by callers, an API that returns nothing aliasing pooled memory, a capacity guard on release, and a test proving a reused object comes back clean.

open as a page