Pointers to Local Variables
How Go safely handles returning pointers to local variables, explained through escape analysis and heap allocation.
In languages like C or C++, returning the address of a local variable is a classic mistake. The variable lives on the stack, and once the function returns, that memory is reclaimed. The pointer becomes dangling — pointing to invalid data that might get overwritten at any moment. Go lets you return pointers to local variables without this risk. The compiler intervenes automatically.
Why Returning a Local Address Fails in Other Languages
A function’s local variables are typically stored in a stack frame. When the function exits, the stack pointer moves, and that memory is considered free. Any future function call may reuse it. If you held onto an address of a local variable, that address now points to garbage or will be silently corrupted.
// C code - dangerous
int* create_int() {
int x = 42;
return &x; // x dies when the function returns
}
In Go, you can write what looks like the same pattern.
func createInt() *int {
x := 42
return &x
}
The returned pointer remains valid. The variable x does not die when createInt returns. This works because of a compiler mechanism called escape analysis.
No magic, just compiler intelligence:
Go does not change how memory works at runtime. It is not that stack variables survive forever. Instead, when the compiler sees that a variable’s address outlives the function, it places that variable on the heap, where the garbage collector manages its lifetime.
How Escape Analysis Makes It Safe
Escape analysis is a compile-time process. The compiler examines each variable and determines whether it “escapes” the function’s scope. A variable escapes if its address can be reached after the function returns — for example, via a return value, a global, or a closure.
When a local variable’s address is returned, the compiler concludes:
- The variable must exist after the function finishes.
- The stack frame will be gone, so the variable cannot live on the stack.
- Therefore, the variable gets allocated on the heap.
This decision happens once, at compile time. There is no runtime copying or moving. The allocation on the heap is what Go refers to as an “allocation.” Allocations are managed by the garbage collector.
A concrete example with compiler output
You can see escape analysis in action by building with the -m flag:
go build -gcflags='-m' main.go
Given the file:
package main
func createInt() *int {
x := 42
return &x
}
func main() {
p := createInt()
println(*p)
}
The compiler reports something like:
./main.go:4:2: moved to heap: x
This line confirms that x was not kept on the stack. It was moved (constructed) on the heap to keep the returned pointer alive.
Everything is working correctly:
If you see moved to heap: x after using -gcflags='-m', the compiler has handled the lifetime correctly. The pointer p in main points to valid memory, and the garbage collector will reclaim it when it is no longer reachable.
The ownership rule for thinking about escape analysis
A mental model that many Go developers use is the ownership rule:
- When a variable is created inside a function, that function owns it.
- Ask: Does this variable need to exist after the owning function returns?
- If no, it can stay on the stack.
- If yes, it must go on the heap.
Returning a value directly (not its address) means the caller gets a copy, and the original variable does not need to outlive the function. Returning a pointer means the caller gets shared access to the original, so that original must survive.
This rule is a simplification — escape analysis is more nuanced — but it covers the most common cases accurately.
What the Programmer Should Actually Do
Go’s safety net is not a license to be sloppy. While returning a pointer to a local variable works, it has implications.
Implicit heap allocations surprise maintainers:
When a function returns &localVar, the code looks like a stack variable, but behind the scenes it causes a heap allocation. This can be confusing in code review and makes memory behavior less explicit. For hot paths or performance-sensitive code, that hidden allocation can matter.
If you want to make the heap allocation obvious, use new or a composite literal with & outside the function:
// Explicit heap allocation
func createIntExplicit() *int {
x := new(int)
*x = 42
return x
}
With this version, no one reading the code will mistake x for a stack variable. The new keyword signals that a pointer to a heap-allocated zero value is created. The escape analysis will confirm it stays on the heap — but the intent is already clear.
However, forcing new everywhere adds noise. For many standard library functions and everyday code, returning &localVar is idiomatic and perfectly fine. The trade-off is readability vs. performance transparency.
Common mistake: believing the variable is on the stack
New Go developers sometimes assume that x := 42 inside a function always lives on the stack. When a pointer to x is returned, they worry the pointer will become invalid. That worry is unnecessary in Go, but the reverse mistake can happen: assuming every local variable escapes to the heap when its address is taken, even if the function never shares that address.
Consider this:
func onlyTakesAddressButDoesNotEscape() int {
x := 10
p := &x
*p = 20
return x // returns value, not pointer
}
Here &x is taken, but the address is never shared outside the function. The compiler is smart enough to recognize that x does not escape, so it stays on the stack. No heap allocation occurs.
Dereferencing nil pointers is still a runtime panic:
Escape analysis does not prevent nil pointer dereferences. If a function returns *int and the caller tries to dereference it before checking for nil, the program panics. Always ensure the pointer is non-nil when returning from a function, or document when nil is a valid return.
Viewing Escape Analysis Decisions
The -m flag shows which variables escape and why. For deeper investigation, -m can be repeated:
-gcflags='-m': basic escape decisions-gcflags='-m -m': additional details, including inlining decisions
Run it on your own functions to see the effect:
type User struct {
name string
age int
}
func newUser(name string, age int) *User {
u := User{name, age}
return &u
}
Output:
./main.go:9:7: &User{...} escapes to heap
The compiler explicitly states that the composite literal escapes to the heap because it is returned as a pointer. That is the mechanism protecting the pointer’s validity.
Practical Use: Returning Pointers from Constructors
Returning a pointer to a local struct is a common pattern in Go constructors:
func NewPerson(name string, age int) *Person {
return &Person{Name: name, Age: age}
}
This is safe, idiomatic, and widely used in production code. The caller receives a pointer to a Person that lives on the heap. No dangling pointer, no undefined behavior.
The compiler’s escape analysis ensures the struct is allocated on the heap. The garbage collector tracks it. When no reference remains, it is freed.
If you prefer to make the allocation explicit, you could write:
func NewPerson(name string, age int) *Person {
p := new(Person)
p.Name = name
p.Age = age
return p
}
Both are correct. The first is more concise and common; the second is more explicit about the pointer nature. Neither is wrong — the choice is about code style and performance awareness.
Returning &T{} is idiomatic:
Most Go codebases use return &T{...} directly. It’s clean, and the escape analysis works transparently. You can safely adopt this pattern.
What About Pointers to Struct Fields from Local Variables?
A related pattern is returning the address of a field inside a local struct:
func (b *B) get1() *A {
localB := B{}
return &localB.A
}
Escape analysis treats the entire localB as escaping because a pointer to its interior is returned. The whole struct will be allocated on the heap, not just the field. This ensures the returned *A points into valid memory. The garbage collector will manage localB until all references to it (including the pointer to its field) are gone.
This behavior is implementation-specific but stable in practice. The Go specification says each variable lives as long as there are references to it, so the programmer does not need to know the internal allocation details.
Performance and Heap Allocations
Heap allocations are not free. They involve extra work for the garbage collector and can increase memory usage. Returning pointers to local variables always causes a heap allocation for the pointed-to value.
In performance-critical code, you might choose to return a value instead of a pointer, keeping the data on the stack:
// Avoids heap allocation for the struct itself
func newUserValue(name string, age int) User {
return User{Name: name, Age: age}
}
The caller receives a copy, which might be larger to copy but avoids an allocation. The decision depends on the struct size and the calling frequency. Profiling with go test -bench and pprof will reveal whether the allocation matters.
For most application code, returning a pointer from a constructor is perfectly fine. The garbage collector in Go is optimized for high allocation rates, and readability matters more than micro-optimizations until proven otherwise.
Summary
Returning a pointer to a local variable in Go is not only safe — it is a deliberate language design supported by escape analysis. The compiler ensures that any variable whose address outlives its function gets placed on the heap. This eliminates dangling pointers and makes patterns like return &T{...} natural and idiomatic.
The key takeaway is not to fear the pattern, but to understand what happens under the hood: an allocation occurs. Use it freely, but when building high-throughput systems, verify with tooling that the allocations are acceptable. Escape analysis transparency is one of Go’s strengths — use -gcflags='-m' to see it in action.