I was reviewing a parser and found a function returning a pointer to a local variable:

func parseCount(s string) (*int, error) {
	n, err := strconv.Atoi(s)
	if err != nil {
		return nil, err
	}
	return &n, nil
}

My C reflex announced that this must be invalid. Go quite deliberately permits it. The compiler sees that n must outlive the call and arranges for it to live on the heap. The pointer remains valid; the cost is allocation and later garbage-collection work.

The useful question was not “is taking a local address legal?” but “where did this value end up, and why?” The compiler can explain with optimization diagnostics. With the current toolchain I use a command along these lines:

$ go build -gcflags=-m
./count.go:8: moved to heap: n
./count.go:8: &n escapes to heap

The wording changes as the compiler evolves, but the report is far more reliable than guessing from syntax. Taking an address does not always force a heap allocation. If the compiler proves the pointer does not outlive its frame, the value can remain on the stack.

For example:

func addOne(p *int) { *p++ }

func work() int {
	n := 4
	addOne(&n)
	return n
}

If addOne is visible and its parameter does not escape, n need not move to the heap. Conversely, code without an obvious new can allocate because a value flows somewhere the analysis cannot bound.

Interfaces are a common source of surprises. I had a logging helper:

func debug(v interface{}) {
	fmt.Fprintln(os.Stderr, v)
}

Passing a local struct through interface{} and into formatting made it harder for the compiler to prove the lifetime. Formatting itself uses reflection and retains enough generality that values may escape. Replacing the call in a hot parser with a concrete, disabled-by-default trace path removed allocations. I did not replace every fmt call in the program; human time is also a resource, occasionally.

Closures can extend lifetimes too:

func counter() func() int {
	n := 0
	return func() int {
		n++
		return n
	}
}

The returned closure needs n after counter returns, so the captured state cannot remain in the departed frame. That is the semantics I asked for. Rewriting it merely to satisfy an allocation count would be silly unless profiles identify this path as important.

I built a benchmark around two count parsers, one returning *int and one returning int plus error:

func BenchmarkPointer(b *testing.B) {
	for i := 0; i < b.N; i++ {
		p, _ := parseCount("42")
		sink = *p
	}
}

func BenchmarkValue(b *testing.B) {
	for i := 0; i < b.N; i++ {
		n, _ := parseCountValue("42")
		sink = n
	}
}

The reduced result looked like:

BenchmarkPointer-4    20000000    92.1 ns/op    8 B/op    1 allocs/op
BenchmarkValue-4      30000000    55.7 ns/op    0 B/op    0 allocs/op

I included a package-level sink so the compiler could not discard the result. I also checked the generated diagnostics after changing code; benchmarks without understanding can reward removal of the work being measured.

Returning the value was the better API anyway. The pointer had been introduced to signal absence, but error already represented failure and every successful parse had a number. One unnecessary state created one unnecessary allocation.

Large values complicate the slogan “prefer values.” Copying a large struct can cost more than indirect access, and a pointer may express shared mutable identity. Escape analysis may also keep a pointer target on the stack when its lifetime is bounded. API semantics come first; diagnostics and benchmarks settle performance questions.

Stack allocation is attractive because reclaiming it is nearly free when a function returns. Heap allocation requires allocator work and gives the garbage collector another object to trace. Yet avoiding every heap object is neither possible nor desirable in a program with goroutines, closures, shared state, and dynamic data structures.

Goroutine arguments deserve the same attention. A closure launched with go can outlive the current function, so captured locals commonly escape. Passing a value as an explicit parameter clarifies what is copied, though it does not guarantee stack allocation when the new goroutine itself needs durable storage. Lifetime, not spelling, drives the decision.

Compiler boundaries matter too. Analysis is strongest when function bodies and call behavior are visible. Calls through interfaces, reflection, or assembly can force conservative decisions because the compiler cannot prove retention does not occur. A future compiler may prove more, another reason not to encode today’s diagnostics as an API religion.

I keep the compiler version beside benchmark results. An escape decision is an implementation result, not a language guarantee, and a toolchain upgrade can legitimately move the allocation again.

My current method is deliberately unromantic. I profile to find an allocation-heavy path, reduce it to a benchmark, ask -gcflags=-m what escapes, and change ownership or API shape if the result remains clear. I do not hunt ampersands by sight. The compiler has a data-flow analysis; I have prejudices from another language. One of those scales better.