Go's Memory Model: Stack, Heap, and Value Semantics
Go is a garbage-collected language with value semantics by default. Variables live in activation records the compiler can place on the stack, on the heap, or in static storage - but you rarely choose where. Instead, you choose types: values, pointers, slices, maps, and interfaces. Those types determine copying behavior, aliasing, and what the runtime must track for the garbage collector.
This page is the conceptual anchor for the Data Types & Memory section. Data Types Basics shows day-to-day syntax; the articles on slices, maps, interfaces, and channels explain runtime representations. Here you learn why Go feels like "pass by value," what "reference type" actually means in Go, and how that differs from Go's separate concurrency memory model (happens-before rules for goroutines).
Summary
- Go variables are values unless you use a pointer; the compiler places storage on stack or heap via escape analysis, and the GC reclaims unreachable heap objects.
- Insight: Aliasing bugs, unexpected copies, and allocation hot spots usually come from misunderstanding value vs pointer semantics - not from memorizing stack frames.
- Key Concepts: value semantics, pointer, escape analysis, slice/map/channel header, zero value, happens-before (concurrency).
- When to Use: Reach for pointers when a function must mutate caller state or when a large struct should not be copied on every call; prefer values when immutability and clarity win.
- Limitations/Trade-offs: You cannot force stack allocation in source code; escape analysis can surprise you after a refactor; pointers and shared headers increase aliasing and race risk.
- Related Topics: slice headers, map buckets, interface itables, escape analysis tooling, race detector.
Foundations
Think of a Go variable as a named box holding a value of a given type. Assignment copies the bits that constitute the value (for most types). Calling a function passes a copy of the argument unless the parameter type is a pointer, in which case the copy is the pointer value (an address), not a deep copy of the pointed-to struct.
Go does not expose malloc/free or Rust-style ownership transfer. Memory lifetime is bounded by scope plus reachability: when the last pointer to a heap object disappears, the GC collects it. That is a different contract from "this value is moved and the old binding is invalid."
Stack and heap are implementation locations, not language keywords. Local variables that do not escape a function often live on the goroutine stack. Values whose addresses outlive the frame (returned pointers, closures capturing locals, storing pointers in globals) move to the heap. You do not annotate this; go build -gcflags=-m reports what the compiler decided.
Several built-in types are reference-like but still passed by value: a slice is a small header (pointer, length, capacity) copied on assignment; maps and channels are descriptors pointing at runtime structures. Copying the header duplicates the handle, not the backing data - two variables can observe the same array or hash table.
Mechanics & Interactions
Escape analysis runs per function during compilation. If the compiler cannot prove a value's address does not leak, it escapes to the heap. Common escape triggers: returning &local, assigning &local to an interface{}, appending &local to a slice of pointers, or capturing a variable in a goroutine that may outlive the frame.
caller frame callee frame
┌─────────────┐ ┌─────────────┐
│ s := []int │ copy hdr │ fn(s []int) │
│ {1,2,3} │ ─────────► │ uses header │
└──────┬──────┘ └─────────────┘
│
▼
backing array (heap or stack - compiler decides)
Value semantics shine in API design: time.Time, small structs, and IDs copy cheaply and reduce hidden mutation. Pointer semantics trade copying for shared state: *Config mutated in one package affects all holders of that pointer. Slices sit in the middle: the header copies, the backing array is shared until append reallocates.
Go's concurrency memory model (documented at go.dev/ref/mem) is orthogonal to stack/heap. It defines which reads and writes are allowed across goroutines via synchronization: channel operations, sync.Mutex, sync/atomic, and sync.Once establish happens-before edges. A data race is two goroutines accessing the same memory, at least one write, without synchronization. The race detector finds those at runtime; escape analysis does not.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
| Pass struct by value | Clear ownership, no nil, fewer races | Copy cost for large structs | Small immutable records, enums, coordinates |
Pass *T | Single shared instance, in-place mutation | Nil checks, aliasing, race risk | Large configs, builders, parsers with state |
| Slice header by value | Cheap pass, shared backing array | Subslice aliasing surprises | Collections, buffers, streaming I/O |
map/chan descriptor | Idiomatic shared maps and signaling | Not safe for unsynchronized map races | Concurrent pipelines with proper sync |
Advanced Considerations & Applications
Micro-optimizing "keep it on the stack" without profiling is usually wasted effort. Modern Go inlines small objects and the Green Tea GC (default in Go 1.26) reduces pause sensitivity for many workloads. When allocation profiles show hot paths, fixes are often: reuse buffers with sync.Pool, preallocate slices (make([]T, 0, n)), pass pointers only where needed, or reduce boxing into interface{}.
Zero values are load-bearing: nil slice and nil map behave differently (nil map write panics; nil slice append works). Interfaces holding typed nil pointers are not equal to nil interface - a common production bug covered in Nil Interface vs Nil Pointer.
For services, value semantics at domain boundaries (return copies, immutable DTOs) plus pointers only inside performance-critical packages is a durable pattern. Pair that with the race detector in CI and mutex/channel discipline from Concurrency Basics.
Common Misconceptions
- "Go is pass-by-reference because slices are references" - Parameters are always passed by value; slice/map/channel values are small headers whose copy still shares backing storage.
- "I can put large structs on the stack with a keyword" - Only escape analysis decides;
new(T)and&T{}can still be stack-allocated if they do not escape. - "Pointers always mean heap" - Non-escaping pointers may point to stack memory; heap vs stack is about lifetime, not
*syntax. - "The memory model document is about stack and heap" -
go.dev/ref/memis about goroutine visibility (happens-before), not allocation placement. - "Copying a slice copies the data" -
copy(dst, src)orappendto a new slice may copy elements; assignment only copies the header.
FAQs
Does Go pass function arguments by reference or by value?
Always by value. If the parameter is *T, the value copied is the pointer (address), not the struct itself. Mutations through that pointer are visible to all code sharing the same address.
What is escape analysis in one sentence?
The compiler analysis that decides whether a variable's storage must outlive its declaring function and therefore must be allocated on the heap.
When should I use a pointer receiver vs a value receiver?
Use pointer receivers when methods mutate state, when the struct is large enough that copying hurts, or when consistency requires all methods on *T. Value receivers suit small immutable types and avoid accidental mutation.
Are maps passed by reference?
Map variables are descriptors passed by value. Assignment or passing to a function copies the descriptor, not the bucket table. Both copies refer to the same map data.
What is the zero value of a pointer?
nil. Dereferencing without a nil check panics. The zero value of *int is nil, not a pointer to zero.
How is Go's concurrency memory model related to stack and heap?
They are separate topics. Allocation placement is compile-time + GC; the memory model governs which cross-goroutine reads/writes are defined when you use sync primitives or channels.
Why does returning a pointer to a local variable work?
If the pointer escapes the function, the compiler allocates the local on the heap so it remains valid after return. If it does not escape, the pointer may refer to stack storage that is only valid while the frame is live.
Do interfaces copy data?
Assigning an interface value copies the interface word pair (type + data pointer). Small values may live inline in the data word; larger values live elsewhere and the interface holds a pointer to them.
What tools show stack vs heap decisions?
go build -gcflags=-m logs escape decisions. CPU and alloc profiles (pprof) show where runtime cost actually lands - use those before rewriting types.
Is stack allocation faster than heap?
Often yes for tiny, non-escaping objects because heap allocation hits the allocator and GC bookkeeping. The gap varies by Go version and workload; measure rather than assume.
Can two goroutines share a stack?
No. Each goroutine has its own stack (grown as needed). Sharing happens through heap objects, globals, or synchronized access to memory they both reference.
What happens when I copy a struct containing a slice field?
The struct copy duplicates every field. The slice field's header is copied, so both structs see the same backing array until one side appends past capacity and reallocates.
Related
- Data Types Basics - runnable intro to types before internals
- Slice Internals - header layout and append growth
- Escape Analysis & Stack vs Heap Allocation - reading
-gcflags=-moutput - Nil Interface vs Nil Pointer - typed nil in interfaces
- Concurrency Basics - channels, mutexes, and happens-before in practice
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).