Addressability and escape

Two questions every C programmer asks in their first week of Go. First: what can I take the address of? Second: if I return &local from a function, am I handing back a dangling pointer? This short lesson answers both.

What you can take the address of

&x needs x to be addressable. Go's rule is that these are addressable:

  • A variable you declared.
  • A field of an addressable struct.
  • An element of an addressable slice or array.

And these are not:

  • An untyped literal (a number, a string constant, a boolean literal).
  • The return value of a function, taken directly from the call expression.
  • An entry in a map.

The compiler rejects the non-addressable cases up front:

p := &42
// compile error: cannot take address of 42
m := map[string]int{"a": 1}
p := &m["a"]
// compile error: cannot take address of m["a"]
func get() int { return 42 }

p := &get()
// compile error: cannot take address of get()

The workaround is always the same: bind the value to a variable first, because variables are addressable:

n := 42
p := &n   // fine, n is a variable

This is why you will see tiny helper functions in Go codebases that exist purely to turn a literal into a pointer, especially around APIs that take optional *T parameters:

func intPtr(n int) *int {
    return &n
}

// use site:
config.Timeout = intPtr(30)   // sets the optional field to 30

n here is a function parameter, which is a variable, which is addressable. Many codebases have generic-looking little utility functions for this (Int, String, Bool); the slices package has them built in as new(T) wrappers. In new Go code you can often use generics to write one Ptr[T any](v T) *T helper.

Returning &local is safe

In C, this function is a classic bug:

int* make_counter(void) {
    int n = 0;
    return &n;   // DANGLING: n's storage ends when make_counter returns
}

n is a stack local, the stack frame disappears on return, and the returned pointer aims at recycled memory. The program might work by accident for a while, then crash in a debugger session three weeks later.

In Go, the equivalent is not only safe, it is common:

func newCounter() *int {
    n := 0
    return &n
}

func main() {
    p := newCounter()
    *p = 42
    fmt.Println(*p)   // 42
}

The compiler notices that n's address leaves the function, and it moves n to the heap instead of the stack. The runtime keeps it alive as long as anything holds a pointer to it, and the garbage collector reclaims it once the last pointer disappears. This is called escape analysis, and it is the main reason Go can have C-like value semantics without C's lifetime bugs.

You do not have to do anything to opt into this. The compiler works out where each variable has to live based on how it is used. A variable whose address never leaves its function stays on the stack (cheap); a variable whose address escapes ends up on the heap (a bit pricier but correct).

Watch escape analysis directly

On your own machine, you can ask the compiler to print its escape decisions with go build -gcflags="-m" (or go run -gcflags="-m" main.go). The output includes lines of the form ./main.go:X:Y: moved to heap: n and ./main.go:X:Y: &n escapes to heap, one per escaping variable. That is the exact report the compiler generates during normal builds; it is already making these decisions, the flag just tells it to narrate them. Exact text and line numbers vary slightly between Go versions, but the "moved to heap" / "does not escape" shape is stable.

Stop worrying about stack vs heap

In Go you generally do not pick which memory area a variable lives in. The compiler picks, based on escape analysis. Write the code that reads clearly, and trust the compiler to place things correctly. The tiny cost of a heap allocation in the rare case where it surprises you is almost never worth reshaping your program to avoid.

This also explains something you saw back in the Functions chapter without knowing. When counter() returned a closure that captured a local n, that n outlived the function call. Same mechanism: the compiler saw n's address escape into the returned closure, and moved n to the heap so it would stay alive for as long as the closure did.

Task

The starter has a newCounter function that returns a *int starting at zero, and a main that calls it.

Wire up main so it:

  • Calls newCounter and stores the returned pointer in p.
  • Writes 42 through the pointer with *p = 42.
  • Prints *p.

The program should print 42. If you come from C, notice that this works: n inside newCounter has escaped to the heap because its address is returned, and the runtime keeps it alive for as long as p refers to it.

Expected output:

42
Expected output
42