Heap vs Stack in Go: How Compiler Knowledge Saves You GC Cycles

In Go, you don’t get to choose - the compiler does. Two sides of memory management most developers have heard about, but few really dig into.

Through escape analysis and inlining, Go decides whether your values stay on the stack or get pushed onto the heap. And the difference matters. Heap allocations add pressure on the garbage collector. Stack allocations vanish with scope.

The same code, written slightly differently, can mean multiple-fold differences in performance (In my benchmarks, returning a struct by value ran in ~12ns with zero allocations, while returning a pointer took ~160ns and allocated memory).


Example 1: Escape Analysis on Structs with Primitives

Consider this program:

package main

type Point struct {
    X, Y int
}

func newPoint(x, y int) Point {
    a := x + y
    b := x * y
    c := a ^ b
    d := (a + b + c) / 3

    // repeat operations to inflate cost
    e := d + a
    f := e*b - c
    g := f ^ d
    h := g + e + f

    if h%2 == 0 {
        h += 1
    } else {
        h -= 1
    }

    return Point{X: h, Y: d}
}

func newPointPtr(x, y int) *Point {
    p := newPoint(x, y) // p is a local variable

	// repeat operations to inflate cost
    a := p.X + p.Y
    b := p.X * p.Y
    c := a ^ b
    d := (a + b + c) / 3
    p.Y = d
    return &p // returning address of local variable, escapes to heap
}

func main() {
    p1 := newPoint(1, 2)
    p2 := newPointPtr(1, 2)

    println(p1.X, p1.Y)
    println(p2.X, p2.Y)
}

Compiled with go build -gcflags=all="-m -m", you might see (Go 1.25.1):

$ go build -gcflags=all="-m -m" main.go 2>&1 | egrep 'main.go'

./main.go:7:6: cannot inline newPoint: function too complex: cost 82 exceeds budget 80
./main.go:28:6: cannot inline newPointPtr: function too complex: cost 108 exceeds budget 80
./main.go:38:6: cannot inline main: function too complex: cost 140 exceeds budget 80
./main.go:29:2: p escapes to heap in newPointPtr:
./main.go:29:2:   flow: ~r0 &p:
./main.go:29:2:     from &p (address-of) at ./main.go:35:9
./main.go:29:2:     from return &p (return) at ./main.go:35:2
./main.go:29:2: moved to heap: p

The newPoint function, which only returns a struct of ints, does not escape — the compiler keeps it on the stack, even though it’s “too complex” to inline.

The newPointPtr function, however, explicitly returns the address of a local variable, so the compiler has no choice: p moves to the heap.

This shows two key ideas:

  • Inlining is limited by a cost budget (functions with too many operations won’t inline, even if they’re stack-only).
  • Returning the address of a local variable always forces a heap allocation.

Example 2: When Structs Contain Heap-Carrying Types

Now, consider a slightly different struct:

package main

type User struct {
	ID   int
	Name string
}

func newUser(id int, name string) User {
	// some computation to increase complexity
	a := 0
	b := 1
	c := 0
	for i := 0; i < id; i++ {
		c, a, b = a+b, b, c
	}
	newId := c + id + (((a+b)%(c+1)) - id) + 1
	if newId != id {
		return User{ID: newId, Name: name}
	}
	if id%2 == 0 {
		name = name + " Sr."
	} else {
		name = name + " Jr."
	}
	return User{ID: id, Name: name}
}

func newUserPtr(id int, name string) *User {
	u := newUser(id, name)
	// some computation to increase complexity
	if u.ID != id {
		u.Name = name + " (Modified)"
	} else {
		u.Name = name + " (Unmodified)"
	}
	return &u // returning address of local variable, escapes to heap
}

func main() {
	user1 := newUser(1, "Alice")
	user2 := newUserPtr(2, "Bob")

	println(user1.ID, user1.Name)
	println(user2.ID, user2.Name)
}

Compiled output might include:

$ go build -gcflags=all="-m -m" main.go 2>&1 | egrep 'main.go'

./main.go:8:6: cannot inline newUser: function too complex: cost 85 exceeds budget 80
./main.go:29:6: cannot inline newUserPtr: function too complex: cost 85 exceeds budget 80
./main.go:39:6: cannot inline main: function too complex: cost 140 exceeds budget 80
./main.go:8:22: parameter name leaks to ~r0 for newUser with derefs=0:
./main.go:8:22:   flow: ~r0 name:
./main.go:8:22:     from User{...} (struct literal element) at ./main.go:18:14
./main.go:8:22:     from return User{...} (return) at ./main.go:18:3
./main.go:22:15: name + " Sr." escapes to heap in newUser:
./main.go:22:15:   flow: name &{storage for name + " Sr."}:
./main.go:22:15:     from name + " Sr." (spill) at ./main.go:22:15
./main.go:22:15:     from name = name + " Sr." (assign) at ./main.go:22:8
./main.go:22:15:   flow: ~r0 name:
./main.go:22:15:     from User{...} (struct literal element) at ./main.go:18:14
./main.go:22:15:     from return User{...} (return) at ./main.go:18:3
./main.go:24:15: name + " Jr." escapes to heap in newUser:
./main.go:24:15:   flow: name &{storage for name + " Jr."}:
./main.go:24:15:     from name + " Jr." (spill) at ./main.go:24:15
./main.go:24:15:     from name = name + " Jr." (assign) at ./main.go:24:8
./main.go:24:15:   flow: ~r0 name:
./main.go:24:15:     from User{...} (struct literal element) at ./main.go:18:14
./main.go:24:15:     from return User{...} (return) at ./main.go:18:3
./main.go:8:22: leaking param: name to result ~r0 level=0
./main.go:22:15: name + " Sr." escapes to heap
./main.go:24:15: name + " Jr." escapes to heap
./main.go:30:2: u escapes to heap in newUserPtr:
./main.go:30:2:   flow: ~r0 &u:
./main.go:30:2:     from &u (address-of) at ./main.go:36:9
./main.go:30:2:     from return &u (return) at ./main.go:36:2
./main.go:29:25: parameter name leaks to u for newUserPtr with derefs=0:
./main.go:29:25:   flow: u name:
./main.go:29:25:     from newUser(id, name) (call parameter) at ./main.go:30:14
./main.go:29:25:     from u := newUser(id, name) (assign) at ./main.go:30:4
./main.go:32:17: name + " (Modified)" escapes to heap in newUserPtr:
./main.go:32:17:   flow: u &{storage for name + " (Modified)"}:
./main.go:32:17:     from name + " (Modified)" (spill) at ./main.go:32:17
./main.go:32:17:     from u.Name = name + " (Modified)" (assign) at ./main.go:32:10
./main.go:34:17: name + " (Unmodified)" escapes to heap in newUserPtr:
./main.go:34:17:   flow: u &{storage for name + " (Unmodified)"}:
./main.go:34:17:     from name + " (Unmodified)" (spill) at ./main.go:34:17
./main.go:34:17:     from u.Name = name + " (Unmodified)" (assign) at ./main.go:34:10
./main.go:29:25: leaking param: name
./main.go:30:2: moved to heap: u
./main.go:32:17: name + " (Modified)" escapes to heap
./main.go:34:17: name + " (Unmodified)" escapes to heap

Even though newUser function returns by value, the escape analysis reports that name leaks to the result. That’s because a string in Go is a header (ptr+len) that points to an underlying backing array, which lives on the heap. The header may live on the stack, but the actual string data remains in the heap.

This demonstrates another key rule: returning structs with reference types (string, []byte, map, chan, interface) almost always involves heap data.

In this output, we can also see the compiler explicitly marking concatenated strings (name + " Sr.", name + " Jr.", name + " (Modified)") as escaping to the heap, as well as the local variable u being moved to the heap in newUserPtr. This illustrates both implicit escapes due to reference types and explicit escapes when returning pointers to locals.


Why This Matters

  • Heap allocations are hidden costs: They may not show up in code review, but they hit you in production.
  • Stack allocations are free riders: They disappear with scope, never touching the GC.
  • Escape analysis is the referee: Use it to guide your code.

Lessons from the Compiler

You can’t manually choose stack vs heap in Go, but you can write code that steers the compiler:

  • Return values, not pointers, when structs are small.
  • Avoid unnecessary closures that capture variables.
  • Inspect -gcflags=all="-m -m" output to see what escapes.
  • For performance-critical paths, lean on profiling and even PGO (Profile-Guided Optimization) in Go 1.21+.

Closing

Not every allocation matters, but at scale, they add up. Knowing how Go’s compiler makes stack vs heap decisions is one of those small insights that separate “works fine” from “scales cleanly.”

It’s not just about Go. It’s about learning to see the invisible decisions your compiler makes, and how they ripple through latency, throughput, and system resilience.


Summary Table

Here’s a quick reference comparing how different return types and field compositions affect escape analysis:

Struct FieldsReturn TypeLikely AllocationCompiler Notes
Only primitives (`int`, `float64`)`Value` (`Point`)StackMay be inlined; no escape.
Only primitives`Pointer` (`*Point`)HeapReturning address of local variable forces escape.
Contains reference type (`string`, `[]byte`, `map`, `chan`, `interface`)`Value` (`User`)Heap (partially)Struct header on stack, but underlying data in heap.
Contains reference type`Pointer` (`*User`)HeapBoth header and data tied to GC.

Further Reading