Go Deep Dive — Memory, Pointers, Value Semantics, and Interfaces
A deep dive into Go memory, pointers, value semantics, and interfaces.
Table of Contents
- Go’s Memory Model — The Big Picture
- Stack vs Heap
- Escape Analysis
- Values and Pointers
- Value Semantics vs Pointer Semantics
- Structs: Copy Semantics in Practice
- Reference-like Types: Slices, Maps, Channels
- Method Receivers: Value vs Pointer
- Interfaces: Internal Representation
- The Nil Interface Trap
- Interface Satisfaction and Method Sets
- Garbage Collection Basics
- Practical Guidelines
- Common Pitfalls Cheat Sheet
1. Go’s Memory Model — The Big Picture
Go is a garbage-collected, statically typed language with value semantics by default. This single design decision — that assignment, function arguments, and struct fields copy values unless you explicitly use a pointer — shapes almost everything about how memory behaves in Go.
Key facts:
- Every variable in Go has a type, and every type has a fixed size known at compile time (except for a few “header” types like slices, strings, maps, which have a fixed-size header pointing to variable-size data).
- Memory in a running Go program lives in one of two places: the stack or the heap.
- The Go compiler decides where a variable lives via a process called escape analysis — not the programmer, and not “new = heap, no new = stack” like in C++.
- There is no manual memory management. The garbage collector (GC) reclaims heap memory automatically.
- Go does not have pointer arithmetic (unlike C/C++), which makes pointers memory-safe by construction (aside from
unsafe.Pointer).
2. Stack vs Heap
The Stack
- Each goroutine has its own stack (starts small, ~8KB, grows/shrinks dynamically up to a limit, typically up to 1GB on 64-bit systems by default).
- Stack allocation is extremely cheap: it’s just moving a stack pointer.
- Stack memory is automatically reclaimed when a function returns — no GC involvement.
- Local variables that do not escape the function are allocated on the stack.
The Heap
- Shared across all goroutines.
- Heap allocation is more expensive: it involves the allocator (
mallocgcin the runtime) and adds pressure on the garbage collector. - Anything that escapes the function (its lifetime outlives the function call, or the compiler cannot prove otherwise) is heap-allocated.
Why This Matters
Every heap allocation is:
- Slower to allocate than a stack allocation.
- A source of GC pressure (the GC has to scan it, potentially move work, etc.)
- A potential cache-locality problem — heap objects are scattered rather than tightly packed like stack frames.
Writing “mechanically sympathetic” Go code often means minimizing unnecessary heap allocations, not by avoiding pointers dogmatically, but by understanding when a pointer forces an escape.
3. Escape Analysis
Escape analysis is a static analysis pass done by the compiler to decide whether a variable can safely live on the stack, or whether it must be moved (“escape”) to the heap.
The Golden Rule
A variable escapes to the heap if the compiler cannot prove that its lifetime is limited to the current function call’s stack frame.
Common Reasons a Variable Escapes
// 1. Returning a pointer to a local variable
func newUser() *User {
u := User{Name: "Ada"} // escapes: caller holds a pointer to it
return &u
}
// 2. Storing a pointer into a struct/slice/map/channel that outlives the function
func store(u *User) {
globalUsers = append(globalUsers, u) // escapes
}
// 3. Passing a value to an interface parameter (often forces boxing/escaping)
func log(v interface{}) {
fmt.Println(v)
}
func doWork() {
x := 42
log(x) // x may escape because the interface value must reference it
}
// 4. Closures capturing variables by reference
func counter() func() int {
count := 0 // escapes: captured by the returned closure
return func() int {
count++
return count
}
}
// 5. Variable size not known at compile time (e.g., slice with dynamic length via make)
func makeSlice(n int) []int {
s := make([]int, n) // if n isn't a small compile-time constant, this escapes
return s
}
How to Check Escape Analysis Yourself
go build -gcflags="-m" ./...
This prints diagnostics like:
./main.go:10:9: &u escapes to heap
./main.go:15:6: moved to heap: u
Running with -m -m gives more detail on why the compiler made that decision.
Important Nuance
Returning a pointer from a function does not automatically mean bad performance. In fact it’s often better than returning a large struct by value (which would copy the whole thing on the stack, or force the caller to escape it anyway). The key insight: escape analysis, not “pointer = heap, value = stack,” is the real rule. A pointer can stay on the stack if it never escapes the calling function’s frame; a value can end up on the heap if it’s captured by something with a longer lifetime.
4. Values and Pointers
What a Pointer Is
A pointer is a variable holding the memory address of another variable. In Go:
var x int = 10
var p *int = &x // p holds the address of x
fmt.Println(*p) // dereference: prints 10
*p = 20 // modifies x through the pointer
fmt.Println(x) // 20
&x— “address of” operator, produces a*Tfrom aT.*p— “dereference” operator, produces aTfrom a*T.- The zero value of any pointer type is
nil. - Dereferencing a
nilpointer causes a runtime panic (invalid memory address or nil pointer dereference).
new vs &T{} vs make
p1 := new(int) // *int, points to a zero-valued int, i.e. 0
p2 := &MyStruct{} // *MyStruct, points to a zero-valued struct literal
s := make([]int, 0, 10) // slice header (not a pointer!), pre-allocated capacity 10
m := make(map[string]int)
ch := make(chan int)
new(T)allocates zeroed storage for aTand returns*T. Rarely used in idiomatic Go;&T{}is more common and more flexible (lets you set fields).makeis only for slices, maps, and channels — it doesn’t return a pointer, it returns an initialized (non-zero) value of that reference-like type, because these types need internal setup (e.g. a slice needs an underlying array allocated).
Pointers Are Not “References” in the C++ Sense
Go pointers are more restricted than C++ references:
- They can be
nil. - They can be reassigned to point elsewhere.
- There’s no operator overloading of
*/&semantics — always explicit. - No pointer arithmetic:
p++on a*intis illegal.
Pointers to Pointers
Legal, but rare in idiomatic code:
var x int = 5
p := &x
pp := &p
**pp = 10 // x is now 10
5. Value Semantics vs Pointer Semantics
This is the conceptual core of “Go semantics.”
Value Semantics
When you pass a value (not a pointer) to a function, assign it to another variable, or store it in a slice/map, Go copies it.
type Point struct{ X, Y int }
func move(p Point) {
p.X += 10 // modifies the COPY only
}
func main() {
pt := Point{1, 2}
move(pt)
fmt.Println(pt) // {1 2} — unchanged!
}
Pointer Semantics
When you pass a pointer, the function operates on the same underlying data as the caller.
func movePtr(p *Point) {
p.X += 10 // modifies the original
}
func main() {
pt := Point{1, 2}
movePtr(&pt)
fmt.Println(pt) // {11 2} — changed!
}
Why Go Defaults to Value Semantics
- Predictability: a function that takes a value can’t mutate caller state (no aliasing bugs) unless it explicitly takes a pointer.
- Cache-friendliness: small values passed by value often stay in registers or on the stack, avoiding heap allocation and pointer chasing.
- Concurrency safety: copies can’t be data-raced the same way shared pointers can (though this isn’t a silver bullet — see below).
The Trade-off
- Value semantics cost a copy — expensive for large structs.
- Pointer semantics cost an indirection and often force a heap escape and add potential for aliasing bugs and, in concurrent code, data races.
There is no universally “correct” choice — it’s a per-type, per-context decision (see Section 13).
6. Structs: Copy Semantics in Practice
Assignment Copies
type User struct {
Name string
Age int
}
u1 := User{"Ada", 30}
u2 := u1 // full copy
u2.Name = "Grace"
fmt.Println(u1.Name) // "Ada" — untouched
fmt.Println(u2.Name) // "Grace"
Struct Containing a Pointer/Slice/Map Field
Copying the struct copies the field itself, not what it points to. For pointer fields, that means the copy shares the same pointee:
type Wrapper struct {
Data *User
}
w1 := Wrapper{Data: &User{"Ada", 30}}
w2 := w1 // copies the pointer value, NOT the User
w2.Data.Name = "Grace"
fmt.Println(w1.Data.Name) // "Grace" — shared!
This is a classic shallow copy gotcha. The same applies to slice, map, and channel fields inside structs — copying the struct copies the slice/map header, but the underlying array/hash table/buffer is shared.
Arrays vs Slices — A Critical Distinction
// Array: value type, fixed size, part of the type itself
a1 := [3]int{1, 2, 3}
a2 := a1 // FULL COPY of all 3 elements
a2[0] = 99
fmt.Println(a1) // [1 2 3] — unaffected
// Slice: reference-like type (header: pointer, len, cap)
s1 := []int{1, 2, 3}
s2 := s1 // copies the HEADER only, same underlying array
s2[0] = 99
fmt.Println(s1) // [99 2 3] — affected!
[3]int and [4]int are different types in Go — array length is part of the type.
7. Reference-like Types: Slices, Maps, Channels
These types are technically value types (you can copy them, they aren’t pointers), but they behave like references because their value is a small header struct containing a pointer to shared underlying data.
Slice Header
type sliceHeader struct {
ptr *T
len int
cap int
}
- Copying a slice copies
ptr,len,cap— three machine words — not the underlying array. appendmay or may not reallocate the underlying array (ifcapis exceeded, a new bigger array is allocated and copied — this is whyappendreturns a new slice, and why you should almost always reassign:s = append(s, x)).- Slicing (
s[1:3]) creates a new header pointing into the same underlying array — mutations via one slice are visible via the other (until a reallocatingappendbreaks the link).
s := []int{1, 2, 3, 4, 5}
sub := s[1:3]
sub[0] = 99
fmt.Println(s) // [1 99 3 4 5] — shared backing array
Map
type hmap struct {
// internal runtime structure — buckets, count, hash seed, etc.
// maps are always passed/copied as a pointer to this structure
}
- Maps are reference types in behavior: copying a map variable copies a pointer to the same underlying hash table.
- Maps are not safe for concurrent read/write without external synchronization (
sync.RWMutexorsync.Map). - The zero value of a map is
nil; reading from anilmap is safe (returns zero value), but writing to anilmap panics.
Channel
- Also reference-like: a channel variable is a pointer to a runtime
hchanstructure. - Copying a channel variable copies the reference, not the queue — both copies refer to the same channel.
String
- Strings are immutable value types, internally a
{ptr *byte, len int}header pointing to read-only bytes. - Copying a string copies the header (2 words) — cheap — but the underlying bytes are shared and safe to share because strings can’t be mutated.
- Converting
string↔[]bytealways copies the underlying bytes (because[]byteis mutable and the runtime must protect the shared string bytes).
8. Method Receivers: Value vs Pointer
type Counter struct{ n int }
func (c Counter) IncByValue() { c.n++ } // operates on a COPY
func (c *Counter) IncByPointer() { c.n++ } // operates on the ORIGINAL
Rules of Thumb
- If the method needs to mutate the receiver, use a pointer receiver. A value receiver method mutating its receiver is a silent no-op from the caller’s perspective.
- If the struct is large, use a pointer receiver to avoid copying on every call.
- Be consistent: if any method on a type uses a pointer receiver, it’s idiomatic (and often necessary for interface satisfaction) to make all methods on that type use pointer receivers.
- If the type contains a
sync.Mutexor other non-copyable field, always use pointer receivers (copying a locked mutex is a bug —go vetwill flag this).
Auto Addressing
Go automatically takes the address when you call a pointer-receiver method on an addressable value:
c := Counter{}
c.IncByPointer() // compiler rewrites to (&c).IncByPointer()
This only works if c is addressable (a local variable, a field, an array element) — it does not work on map values or literal values:
m := map[string]Counter{"a": {}}
m["a"].IncByPointer() // COMPILE ERROR: cannot call pointer method on m["a"] (not addressable)
9. Interfaces: Internal Representation
An interface value in Go is internally represented as a two-word structure:
type iface struct {
tab *itab // pointer to type info + method table (for non-empty interfaces)
data unsafe.Pointer // pointer to the underlying concrete value
}
// for the empty interface{} (or `any`), it's:
type eface struct {
_type *_type
data unsafe.Pointer
}
datais always a pointer — even if you store anintin aninterface{}, Go allocates the int somewhere (often on the heap, this is called “boxing”) and stores a pointer to it, plus a pointer to type metadata.- This is why assigning a value to an interface can cause an escape to the heap — the interface has to keep a stable address to point to.
- Comparing two interface values (
==) compares both the type and the underlying value (via pointer dereference for the dynamic value comparison, following normal==rules of the underlying type). If the underlying type isn’t comparable (e.g., a slice), comparing panics at runtime.
Example: Boxing Cost
var i interface{} = 42 // 42 is boxed: heap-allocated int + eface{type: int, data: ptr}
Small integers might sometimes avoid heap allocation via compiler optimizations/static tables for common small values, but in general, assume boxing has a cost and avoid interface conversions in hot loops when possible.
10. The Nil Interface Trap
This is one of the most infamous Go gotchas.
type MyError struct{}
func (e *MyError) Error() string { return "boom" }
func mayFail() error {
var e *MyError = nil
return e // returns a NON-NIL error interface!
}
func main() {
err := mayFail()
fmt.Println(err == nil) // false!!
}
Why This Happens
An interface value is nil only if both the type pointer and the data pointer are nil. Here, mayFail() returns an error interface where:
tab/_type=*MyError(a concrete, non-nil type descriptor)data=nil(the pointer value happens to be nil)
The interface itself is not nil — it holds a “typed nil”. This is why the idiomatic pattern is:
func mayFail() error {
var e *MyError = nil
if someCondition {
e = &MyError{}
}
if e == nil {
return nil // explicit: return a truly nil interface, not a typed nil
}
return e
}
Rule: never return a concrete nil pointer through an interface-typed return value unless you actually mean “there is a value, and it’s a nil pointer.” When in doubt, return the literal nil directly for the interface type.
11. Interface Satisfaction and Method Sets
Method Sets
| Receiver type in method declaration | Included in method set of T | Included in method set of *T |
|---|---|---|
func (t T) M() | ✅ Yes | ✅ Yes |
func (t *T) M() | ❌ No | ✅ Yes |
This means:
type Speaker interface{ Speak() string }
type Dog struct{}
func (d *Dog) Speak() string { return "Woof" }
var s Speaker = Dog{} // COMPILE ERROR: Dog does not implement Speaker (Speak has pointer receiver)
var s Speaker = &Dog{} // OK
Why This Rule Exists
If Speak mutates the receiver (or is defined with a pointer receiver for consistency/performance), calling it through a T value in the method set would require the compiler to silently take the address of a possibly non-addressable/temporary value — Go disallows this ambiguity by excluding pointer-receiver methods from the value type’s method set.
Implicit / Structural Interface Satisfaction
Go interfaces are satisfied implicitly — there’s no implements keyword. Any type with the matching method set satisfies the interface automatically. This enables:
- Decoupling: packages don’t need to import an interface’s package to satisfy it.
- Retroactive interfaces: you can define a narrow interface after the fact, near the point of use (a very idiomatic Go pattern: “accept interfaces, return structs”).
Interface Embedding
type Reader interface { Read(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }
type ReadWriter interface {
Reader
Writer
}
Composition over inheritance — small, single-method interfaces (io.Reader, io.Writer, io.Closer) are composed into larger ones as needed.
12. Garbage Collection Basics
Go’s GC is a concurrent, tri-color mark-and-sweep collector with the following properties:
- Runs concurrently with your program (mostly) — a very short “stop the world” phase for setup/handoff, but most marking happens concurrently alongside your goroutines.
- Non-generational, non-compacting: unlike Java’s GC, it doesn’t move objects around in memory (this matters for
unsafe.Pointercode and interop) or segregate by object age. - Tuned via
GOGC(target heap growth percentage before next GC, default 100%) andGOMEMLIMIT(a soft memory limit, added in Go 1.19). - Because Go has no compaction, heap fragmentation is a real (if usually minor) consideration in long-running, allocation-heavy services.
What Triggers GC Pressure
- Heap allocations from escaped variables (see Section 3).
- Boxing values into interfaces (see Section 9).
- String ↔ []byte conversions.
- Closures capturing variables by reference.
- Growing slices/maps beyond capacity repeatedly (reallocations).
Reducing GC Pressure
- Reuse buffers with
sync.Pool. - Pre-allocate slices/maps with known capacity (
make([]T, 0, n)). - Avoid unnecessary interface boxing in hot paths.
- Profile with
pprof(go tool pprof) and-gcflags="-m"before optimizing — don’t guess.
13. Practical Guidelines
When to Use a Pointer Receiver / Pointer Parameter
- The function/method needs to mutate the argument.
- The value is large (rule of thumb: bigger than ~3-4 machine words, i.e., structs with many fields, or containing arrays).
- The type contains a mutex,
sync.WaitGroup, or other type that must not be copied. - You need to represent “no value” via
nil(optional fields). - Consistency: other methods on the type already use pointer receivers.
When to Use a Value
- The type is small (a few ints, a couple of strings, a small struct like
time.Duration,image.Point). - Immutability is desired — passing a value guarantees the callee can’t affect caller state.
- The type is a basic “value object” conceptually (like a coordinate, a money amount, a duration).
- Concurrency: sharing values by copy instead of by pointer sidesteps a whole category of data race bugs (though you must still be careful with fields that are themselves reference types like slices/maps).
General Idioms
io.Reader,io.Writer,error— accept these as interfaces in function signatures; this is the “accept interfaces, return concrete structs” idiom.- Prefer returning concrete types from constructors (
func NewFoo() *Foo), not interfaces, unless you have multiple implementations and need to hide them. - Don’t take the address of a loop variable and store it for later use without understanding the (now largely fixed as of Go 1.22) per-iteration variable semantics — in Go < 1.22, loop variables were reused across iterations, a classic bug source.
// Pre-Go 1.22 bug pattern:
var funcs []func()
for _, v := range []int{1, 2, 3} {
funcs = append(funcs, func() { fmt.Println(v) })
}
// old Go: prints 3, 3, 3 (all closures shared the same v)
// Go 1.22+: prints 1, 2, 3 (each iteration has its own v)
14. Common Pitfalls Cheat Sheet
| Pitfall | Explanation | Fix |
|---|---|---|
| Typed nil in interface | var p *T = nil; var i I = p → i != nil | Return nil literal explicitly, or check the concrete pointer before assigning |
| Mutating through value receiver | Method changes don’t persist | Use pointer receiver |
| Copying a struct with a mutex | sync.Mutex copied → broken locking | Use pointer receivers/pass by pointer; go vet catches this |
Slice aliasing after append | Two slices may or may not share backing array after append | Don’t assume sharing after any append; re-slice deliberately if needed |
| Map is not safe for concurrent access | Concurrent read/write panics (“concurrent map read and map write”) | Use sync.RWMutex or sync.Map |
Writing to a nil map | Panics at runtime | Initialize with make(map[K]V) first |
| Calling pointer method on unaddressable value | Compile error on map values/literals | Assign to a variable first |
| Interface boxing in hot loop | Hidden heap allocations, GC pressure | Avoid unnecessary interface{} conversions in hot paths; profile |
| Comparing interfaces with uncomparable dynamic type | Runtime panic on == | Know the underlying type, or use reflect.DeepEqual carefully |
| Large struct passed by value repeatedly | Copy overhead on every call | Use pointer receiver/parameter for large structs |
Summary
Go’s design philosophy is: value semantics by default, pointer semantics by explicit choice, all governed under the hood by an escape-analysis-driven stack/heap decision and a concurrent garbage collector. Interfaces add a layer of indirection (a fat two-word header pointing to type info and boxed data) that unlocks polymorphism but comes with real performance and correctness gotchas — especially the infamous typed-nil trap. Mastering Go means internalizing when data is copied and when it’s shared, and making that choice deliberately rather than by accident.