JavaRush /Courses /Go SELF /Escape analysis

Escape analysis

Go SELF
Level 19, Lesson 3
Available

1. Simplified memory model: stack and heap

If you have ever heard the phrase “everything allocates on the heap, so it’s slow”, then you already know there is a whole mythology around memory in Go. Beginners usually fear two things: “you can’t return the address of a local variable” and “heap is bad”. In reality, in Go both ideas are most often false or, at the very least, overly dramatic.

In Go there is no manual memory freeing. That means if the language allows you to write return &x, it must make it safe. This is where escape analysis comes in: a compiler analysis that decides: can this value live “locally” within the function call, or does it need to live longer?

To live comfortably in Go, a very simple model is enough. We deliberately won’t dive into low-level details, but we’ll take the “skeleton” of the concepts.

Stack: “a box for things during the call”

When a function is called, it needs local variables: x, sum, and some temporary values. Imagine that each function call is a phone conversation, and the stack is the desk where you put notes “for the duration of the conversation”. The conversation ends — you can throw the notes away.

In programming terms, it looks like this: stack data lives while the function is executing (more precisely, while its “frame” is alive).

Heap: “a warehouse for things that must outlive the call”

The heap is a memory area for data that may live longer than the current function call. For example, if a function created a value and returned a pointer to it, it’s obvious: that value must exist even after the function finishes.

In Go, a garbage collector (GC) makes sure “trash” does not accumulate in that “warehouse”. It walks the object graph in the heap starting from “roots” (globals, values on the stack, etc.) and deletes what becomes unreachable. This idea — “heap as an object graph and a GC that walks from roots” — is the basic mental model of GC in Go.

Mini-table: how the stack differs from the heap

Property Stack Heap
Lifetime Usually limited to the function call Can live longer than the function
“Who cleans up” Automatically on function exit GC removes what is unreachable
Why it exists Local temporary values Anything that must “outlive” the current frame

And yes, in reality everything is more complicated: there are optimizations, different strategies, and nuances. But for us right now the important practical idea is: if a value is needed after the function returns, the compiler must ensure it has the right lifetime.

3. Escape analysis in plain English

Escape analysis is the compiler’s idea to understand whether a value “escapes” the boundaries of the current function call.

If a value does not escape, it can be placed so that it lives exactly as long as needed (often on the stack).

If a value does escape (for example, we return a pointer to it or store that pointer somewhere outside), the compiler places the data so it can live longer. In practical explanations people usually say: “the value will move to the heap”.

Important: as a Go programmer, you do not have to choose manually where exactly a variable will be stored. That is part of the compiler’s and runtime’s job.

An interesting historical fact: in older versions of Go there were even bugs in the escape analysis implementation, and it could lead to very unpleasant consequences. Release notes mention fixing an “escape analysis bug” that could lead to memory corruption — so this is not “cosmetic”; it is genuinely important for correctness.

4. Why return &x is safe

Now comes the main anti-myth of the day.

In many languages (especially C/C++), you can’t return the address of a local variable: the function ends, the stack frame disappears, and the pointer becomes “dangling”.

In Go you can write this — and it is correct:

package main

import "fmt"

func NewInt(v int) *int {
	x := v
	return &x
}

func main() {
	p := NewInt(10)
	fmt.Println(*p) // 10
}

Why does it work? Because the compiler sees: “we return the address of x to the outside”. So x must live longer than the call to NewInt. The compiler arranges allocation so the pointer remains valid after return. That is the intuitive essence of escape analysis.

It is important not to mix up the meaning: we are not asking Go to “allocate on the heap”. We are asking: “give me a pointer that will live”. And where exactly to place the data is up to the compiler.

5. new does not mean “heap”, and & does not mean “stack”

In a beginner’s head, a scary rule-of-thumb often appears:

  • “If new — then heap”
  • “If it’s a local variable — then stack”
  • “If &x — then stack, but we returned it, so everything will break”

None of that works as a reliable set of rules.

In Go, what matters is not “how you got the address”, but where that address ends up and how long it will be needed.

Example: new(int) gives a pointer, but doesn’t force you to think about placement

package main

import "fmt"

func main() {
	p := new(int)     // *int
	*p = 7
	fmt.Println(*p)   // 7
}

The meaning of new(int) is to get a *int pointing to the zero value. And where that value ends up (and whether it gets optimized) is not your concern at the beginner level. In our mental model we might say “most likely heap”, but it is more correct to think: “the compiler will make it correct”.

Example: &v inside a function does not have to “escape”

package main

import "fmt"

func addOne(v int) int {
	p := &v
	*p = *p + 1
	return v
}

func main() {
	fmt.Println(addOne(10)) // 11
}

Here we took the address of the parameter v, but we did not return p to the outside and did not store it anywhere globally. For the compiler, this is a good candidate for “does not escape”.

And again: we do not need to prove where this is stored right now. We need to understand when there is a need to extend a value’s lifetime at all.

6. Practical tie-in

Now we’ll do a very practical piece that often appears in real code: a value may be present, or it may be absent. For example, a user adds an item to a shopping list, and the quantity is optional: you can write milk 2, or you can just write milk -.

We are not using structs (they will come later), so we’ll make a simple model with two parallel slices:

  • names []string — names
  • qtys []*int — quantity (a pointer to an int), where nil means “not provided”

Yes, parallel slices look a bit “old-school”, but this is a good workout for pointers and nil.

Parse an “optional number” and return *int

Pay attention: the function creates a local variable v and returns its address. This is exactly where escape analysis kicks in: the value must outlive the function.

package main

import (
	"fmt"
	"strconv"
)

func parseOptionalInt(s string) (*int, error) {
	if s == "-" {
		return nil, nil
	}

	v, err := strconv.Atoi(s)
	if err != nil {
		return nil, fmt.Errorf("bad number %q: %v", s, err)
	}

	return &v, nil
}

If you feel an inner scream like “you can’t do that, it’s a local variable!” — congratulations, you just found a C programmer inside you, even if you never studied C. In Go this is normal: v “escapes” to the outside, the compiler sees it and ensures the correct lifetime.

Format the quantity for printing

package main

import "fmt"

func formatQty(q *int) string {
	if q == nil {
		return "(no quantity)"
	}
	return fmt.Sprintf("%d", *q)
}

There is an important discipline here: if the type is *int, then nil is a normal value, and your code must be able to live with it, otherwise you will get a panic.

Add a record to our slices

package main

func addItem(names []string, qtys []*int, name string, qty *int) ([]string, []*int) {
	names = append(names, name)
	qtys = append(qtys, qty)
	return names, qtys
}

Notice a nice detail: we return the updated slices. We already know that append can reallocate, so it’s important to keep the result.

Mini printout of the list

package main

import "fmt"

func printItems(names []string, qtys []*int) {
	for i := range names {
		fmt.Printf("%d) %s %s\n", i+1, names[i], formatQty(qtys[i]))
		// example: "1) milk 2"
		// or:      "2) bread (no quantity)"
	}
}

At this point it is useful to remember that slices and pointers easily form “connections” (aliasing). We are deliberately making qtys as []*int, because we want optionality. But the more pointers you have, the more discipline you need.

7. Why the heap is fine

Beginners often associate the heap with “slow” and “scary”, like the node_modules folder: useful, but you’d rather not go in there without a flashlight.

In Go, the heap is a normal part of a program’s life. Yes, the GC watches the heap. But the GC is designed to be practical: it starts walking from the roots (including the stack) and checks which objects are still reachable and which can already be freed.

This leads to an important engineering idea: making code correct and readable matters more than panicking about avoiding the heap. If you need a pointer to express meaning (for example, “a value may be absent”), that is a good reason.

Sometimes heap allocations do matter for performance, but that becomes relevant when you already have profiling, metrics, and an understanding of bottlenecks. At the current learning level, “optimizing by guessing based on pointers” usually ends with code that is more confusing and not faster.

How to recognize “escaping” by eye: typical situations

Now I will describe the situations as connected text (without checklists), because it is more useful to learn to see the meaning rather than memorize spells.

If a function returns *T, then almost always inside it will either use new(T), or take an address with &x. In both cases the result lives after the function returns, so the value must “outlive” the call.

If you put &x into a slice of pointers ([]*int) and that slice is returned to the outside or stored somewhere outside the function, that is also a classic “escape” case: you explicitly keep the address for later.

If the pointer stays inside the function and is not stored anywhere, then the compiler can often keep the data “local”. But (and this is the key point) we do not build code based on guesses about placement. We build code based on correctness and API meaning.

8. Typical mistakes

Mistake #1: “return &x is not allowed, because x is local”.
This is a persistent myth from languages with manual memory management. In Go, returning the address of a local variable is allowed: the compiler accounts for such cases and ensures the correct lifetime via escape analysis. If you forbid yourself from writing return &x, you often end up writing weird constructs that are simply harder to read.

Mistake #2: “I will avoid the heap at any cost, so I’ll make everything global”.
Global variables do live “a long time”, but bugs live a long time with them: unexpected shared state, testing difficulties, implicit dependencies. This is almost always worse than a careful return &x where it makes sense.

Mistake #3: dereferencing nil, because “there should be a number there”.
As soon as you chose the model *int as an “optional number”, you signed a contract: nil is a valid state. That means any output, any arithmetic, and any comparison must either handle nil, or explicitly forbid it at the boundary (and check).

Mistake #4: “new = heap, make = heap, & = stack”, and decisions are built from that.
In Go it matters more not “which operator you used to get a value”, but “how long it must live and where references to it ended up”. new just returns *T, make initializes the internal structure of collections, and the actual placement is the compiler’s and runtime’s job.

Mistake #5: overcomplicating things for micro-optimization.
Sometimes, after learning about the heap, a student starts rewriting code so that “nothing allocates”, but loses clarity: extra parameters appear, strange pointers to slices, lots of manual initializations. At this stage of the course, it is better to stick to the principle: start with a clear contract and correctness, and worry about performance only when you have facts (measurements) and a clear goal.

1
Task
Go SELF, level 19, lesson 3
Locked
Surviving pointer
Surviving pointer
1
Task
Go SELF, level 19, lesson 3
Locked
Optional integer
Optional integer
1
Task
Go SELF, level 19, lesson 3
Locked
Shopping List
Shopping List
1
Task
Go SELF, level 19, lesson 3
Locked
Squares in Memory
Squares in Memory
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION