Understanding Memory Management in GO – Part 1: Stack vs Heap, Allocation Semantics and Escape Analysis


Before diving deep into memory management in Go, let’s first understand some general foundational concepts.
Stack vs Heap: The Two Primary Memory Regions
Stack
A Last-In-First-Out (LIFO) memory structure.
Variables with limited scope and lifetime (like local variables in a function) are usually allocated on the stack.
Memory is automatically reclaimed when the function ends.
Allocation is continuous, and the compiler adjusts the stack pointer as functions are called and return.
When a function exits, the stack pointer simply moves back, making the memory available for reuse without needing to erase it.
Heap
Used for variables that need to outlive the function where they’re created.
Memory must be explicitly allocated and freed (in Go, freed by the garbage collector).
Allocation is non-continuous—memory can be freed in any order, causing fragmentation.
Many heap allocation strategies exist to reduce fragmentation and use memory efficiently.
Value vs Pointer Semantics in Go
This might feel like a detour, but understanding value vs pointer semantics is crucial because it directly impacts memory allocation decisions.
A pointer in Go holds the memory address of a value. When you use a pointer, you share the actual data. Multiple parts of your program can modify it.
A value, on the other hand, creates a copy. Changes to one copy don’t affect others.
In Go:
&
gets the address of a variable.*
dereferences a pointer to access the value it points to.
Pointers are great when:
You're dealing with large data, and copying it is expensive.
You want multiple parts of your program to modify the same data.
Values are helpful when:
You want immutability.
You want to avoid bugs caused by shared access to mutable data.
Go’s Memory Allocation Mechanisms
Go provides two built-in ways to allocate memory:
new(T)
Allocates memory for a single value of type
T
and sets it to its zero value.Returns a pointer to the allocated memory (
*T
).
make(T, args…)
Used only for slices, maps and channels.
Initializes and sets up their internal data structures.
Returns a value of type T, not a pointer.
Note: Unlike other languages, Go zeroes out memory when using new, so the value is safe to use without further initialization. This makes it easier to design data structures where the zero value is meaningful.
Built-in Reference Types
Let’s look at how Go allocates memory for reference types like slices, maps, and channels.
make([]T, len, cap)
Allocates memory for a slice header (pointer, length, capacity).
Creates a backing array to store actual elements.
make(map[K]V)
- Allocates memory for internal hash map buckets to store key-value pairs.
make(chan T, capacity)
- Allocates memory for the channel structure, including an internal buffer if
capacity > 0
.
Composite Literals in Go
Go also lets you allocate and initialize memory using composite literals, which act like constructors:
Composite literals allocate memory too:
For slices (
[]int{1, 2, 3}
), a backing array is allocated.For maps (
map[string]int{...}
), the internal hash structure is created.For structs/arrays (
MyStruct{...}
), memory is allocated for the entire value.
Escape Analysis in Go
Now that we have understood how Go allocates memory, we haven’t yet determined where (stack or heap) the memory would be allocated. That is where escape analysis comes in.
The Go compiler uses escape analysis to determine whether a variable:
Can be safely kept on the stack (fast, no GC), or
Needs to be allocated on the heap (slower, managed by GC).
Common reasons for a variable to escape to the heap:
→ Returning a pointer to a local variable
func escapeLocal() *int {
x := 10
return &x // x escapes to heap
}
→ Sending pointers over channels
func sendPointer(ch chan *int) {
x := 100
ch <- &x // x escapes to heap
}
→ Variable captured by a closure
func closureExample() func() {
x := 42
return func() {
fmt.Println(x) // x escapes to heap
}
}
→ Storing a pointer in a slice/map that itself escapes
var global []*int
func storeInSlice() {
x := 5
global = append(global, &x) // x escapes to heap
}
→ Variable size unknown or huge
func largeArray() {
x := [1 << 20]int{} // large array, likely escapes
fmt.Println(x)
}
In order to observe escape analysis : Use the -gcflags="-m"
compiler flag.
go build -gcflags="-m" .
go run -gcflags="-m" main.go
Look for output like variable escapes to heap
, variable moved to heap
. Also notice when variables don’t have such messages - they likely stay on the stack.
Why fmt.Println
Can Cause Heap Allocations
A common point of surprise when analyzing Go performance is seeing heap allocations originate from simple fmt.Println
calls. This behavior stems directly from its flexible function signature: func Println(a ...interface{})
.
The key is the ...interface{}
part, signifying Println
accepts any number of arguments of any type. When you pass a concrete value (like an int
, float
, or your own struct
) to Println
, Go needs to convert it into an interface{}
value. This "boxing" process creates an interface value containing type information and a pointer to the actual data.
Here's the catch: this newly created interface{}
value, holding a pointer to your data, is then passed to the Println
function. When a pointer to data (wrapped inside an interface value) is passed to another function (especially one as generic as fmt.Println
), the analysis often conservatively assumes the receiving function might cause the pointer to escape further. Proving it won’t escape is the harder task, especially those in different packages (fmt
is a standard library package), is challenging. The fmt
package uses reflection (reflect
package) internally to handle arbitrary types, adding another layer of complexity that's hard for static analysis to fully penetrate and guarantee behavior for all possible types and inputs. Passing data via an interface{}
to an external function is a strong signal to the escape analysis that the data might be needed beyond the current function's scope.
Remember escape analysis is designed to be conservative, it is ok to allocate a variable on heap that was supposed to be allocated on stack, but the converse is dangerous.
Coming up in Part 2, we’ll explore:
Go’s Garbage Collector (GC)
How to observe memory usage
Profiling techniques
GC tuning strategies
Runtime internals related to memory
Subscribe to my newsletter
Read articles from Likhith R Krishna directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
