DEV Community

Pavel Sanikovich
Pavel Sanikovich

Posted on • Edited on

Go From Zero to Depth — Part 2: Go Memory Model Explained Simply (But Correctly)

Beginners usually hear a reassuring story about memory in Go: small things live on the stack, large things live on the heap, the garbage collector handles cleanup, and escape analysis is a mysterious optimization pass that decides where everything goes. This story is simple, but it hides the actual forces that shape how Go works. The reality is both subtler and more interesting, and once you see it, the way you write Go code changes forever.

The right way to understand Go’s memory model is to forget about stack and heap for a moment and focus instead on the nature of values. Go is a value-oriented language. Everything starts with a value: an integer, a struct, a slice header, a map handle. The crucial question is not “where” the value lives but “how long” it needs to live. Lifetime, not size, governs Go’s memory behavior.

A stack frame lives only as long as a function call. If a value can stay within that frame safely, it will. That decision is the essence of escape analysis. Nothing mystical is happening: the compiler simply looks at each value and decides whether it can remain tied to the call stack or whether something forces it to survive beyond the function that created it.

To see how straightforward this is, consider the simplest possible example:

func makeNumber() int {
    x := 42
    return x
}
Enter fullscreen mode Exit fullscreen mode

If you ask Go to print escape analysis results, you will see nothing dramatic:

$ go build -gcflags="-m"
<no escape>
Enter fullscreen mode Exit fullscreen mode

The compiler sees that x never leaves this function except as a copy. It can happily live on the stack.

But now make a tiny change:

func makeNumberPtr() *int {
    x := 42
    return &x
}
Enter fullscreen mode Exit fullscreen mode

Run the same command:

./main.go:4:9: &x escapes to heap
Enter fullscreen mode Exit fullscreen mode

The value x must now outlive the stack frame of makeNumberPtr, because the caller receives a pointer to it. Go cannot keep x on the stack anymore. Nothing about the size or complexity changed. Only the lifetime changed. The moment you return a pointer, your value escapes.

This is where beginners usually pause. Returning a pointer feels harmless, even idiomatic, yet it changes how memory is managed. This is why thinking in terms of ownership is more important than memorizing stack vs heap rules. A pointer implies shared ownership. A value implies independent ownership. The compiler simply enforces this distinction.

Closures reveal this idea even more clearly. Consider this common pattern:

func counter() func() int {
    x := 0
    return func() int {
        x++
        return x
    }
}
Enter fullscreen mode Exit fullscreen mode

Here x is referenced by a function literal that survives after counter returns. Go performs the only correct action:

./main.go:6:13: func literal escapes to heap
./main.go:5:5: moved to heap: x
Enter fullscreen mode Exit fullscreen mode

When you run the program, every call increments the same x, and that would be impossible if x had stayed on the stack. The heap is not a performance penalty here; it is the correct expression of the variable’s lifetime.

Not all escapes are required. Some are accidental. A lot of performance issues in Go come from patterns that unknowingly extend lifetimes.

Compare two versions of a constructor:

type User struct {
    Name string
}

func newUser(name string) User {
    return User{Name: name}
}
Enter fullscreen mode Exit fullscreen mode

Now the pointer version:

func newUserPtr(name string) *User {
    return &User{Name: name}
}
Enter fullscreen mode Exit fullscreen mode

In the first case, User is returned by value. The compiler can often avoid heap allocation entirely by placing the struct directly in the caller’s stack frame. In the second case, returning a pointer forces escape. A beginner may prefer pointer semantics because they “feel cleaner,” but the cost appears only when you look deeper.

The same applies to slices and maps. A slice header is a small struct containing a pointer to underlying data. Returning a slice does not necessarily cause a heap escape; returning a pointer to part of a slice almost always does. Ownership and lifetime drive everything.

The garbage collector fits neatly into this picture. It is not sweeping memory randomly; it is following chains of references established by your program. If nothing points to a value, the value dies. If something still points to it, the value lives. This makes clarity of ownership a performance feature. The simpler your reference graph, the less work the GC performs.

Understanding all of this does not require deep systems knowledge. It requires only the willingness to look under the surface and recognize that Go’s model is not magical. It is logical. And once you see the logic, your intuition sharpens. You start predicting when a value will escape. You notice unnecessary pointers. You understand how closures extend lifetimes. You become aware that Go optimizes for the code you write, not the code you intended.

The Go memory model is approachable because the language removes distractions. There is no manual memory management, no pointer arithmetic, no undefined behavior. What remains is a direct connection between the structure of your program and the shape of its memory. For a beginner, discovering this is often the moment Go stops looking “too simple” and starts looking deeply intentional.

In the next part, we will examine escape analysis more closely. We will look at stack vs heap behavior in detail, explore how the compiler decides where values go, and demonstrate how small changes in code ripple through performance. If Part 2 revealed the shape of Go’s memory model, Part 3 will show how to work with it deliberately.

The value of Go lies not in hidden complexity but in transparent mechanisms that reward clear thinking. The memory model is the first place where this becomes unmistakable.

Want to go further?

This series focuses on understanding Go, not just using it.

If you want to continue in the same mindset, Educative is a great next step.

It’s a single subscription that gives you access to hundreds of in-depth, text-based courses — from Go internals and concurrency to system design and distributed systems. No videos, no per-course purchases, just structured learning you can move through at your own pace.

👉 Explore the full Educative library here

Top comments (0)