Go Deep Dive — Memory, Pointers, Value Semantics, and Interfaces

A deep dive into Go memory, pointers, value semantics, and interfaces.

🌱 Seedling·created: ·category:Golang

Table of Contents

  1. Go’s Memory Model — The Big Picture
  2. Stack vs Heap
  3. Escape Analysis
  4. Values and Pointers
  5. Value Semantics vs Pointer Semantics
  6. Structs: Copy Semantics in Practice
  7. Reference-like Types: Slices, Maps, Channels
  8. Method Receivers: Value vs Pointer
  9. Interfaces: Internal Representation
  10. The Nil Interface Trap
  11. Interface Satisfaction and Method Sets
  12. Garbage Collection Basics
  13. Practical Guidelines
  14. 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 (mallocgc in 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:

  1. Slower to allocate than a stack allocation.
  2. A source of GC pressure (the GC has to scan it, potentially move work, etc.)
  3. 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 *T from a T.
  • *p — “dereference” operator, produces a T from a *T.
  • The zero value of any pointer type is nil.
  • Dereferencing a nil pointer 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 a T and returns *T. Rarely used in idiomatic Go; &T{} is more common and more flexible (lets you set fields).
  • make is 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 *int is 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

  1. Predictability: a function that takes a value can’t mutate caller state (no aliasing bugs) unless it explicitly takes a pointer.
  2. Cache-friendliness: small values passed by value often stay in registers or on the stack, avoiding heap allocation and pointer chasing.
  3. 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.
  • append may or may not reallocate the underlying array (if cap is exceeded, a new bigger array is allocated and copied — this is why append returns 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 reallocating append breaks 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.RWMutex or sync.Map).
  • The zero value of a map is nil; reading from a nil map is safe (returns zero value), but writing to a nil map panics.

Channel

  • Also reference-like: a channel variable is a pointer to a runtime hchan structure.
  • 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[]byte always copies the underlying bytes (because []byte is 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

  1. 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.
  2. If the struct is large, use a pointer receiver to avoid copying on every call.
  3. 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.
  4. If the type contains a sync.Mutex or other non-copyable field, always use pointer receivers (copying a locked mutex is a bug — go vet will 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
}
  • data is always a pointer — even if you store an int in an interface{}, 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 declarationIncluded in method set of TIncluded 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.Pointer code and interop) or segregate by object age.
  • Tuned via GOGC (target heap growth percentage before next GC, default 100%) and GOMEMLIMIT (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

PitfallExplanationFix
Typed nil in interfacevar p *T = nil; var i I = pi != nilReturn nil literal explicitly, or check the concrete pointer before assigning
Mutating through value receiverMethod changes don’t persistUse pointer receiver
Copying a struct with a mutexsync.Mutex copied → broken lockingUse pointer receivers/pass by pointer; go vet catches this
Slice aliasing after appendTwo slices may or may not share backing array after appendDon’t assume sharing after any append; re-slice deliberately if needed
Map is not safe for concurrent accessConcurrent read/write panics (“concurrent map read and map write”)Use sync.RWMutex or sync.Map
Writing to a nil mapPanics at runtimeInitialize with make(map[K]V) first
Calling pointer method on unaddressable valueCompile error on map values/literalsAssign to a variable first
Interface boxing in hot loopHidden heap allocations, GC pressureAvoid unnecessary interface{} conversions in hot paths; profile
Comparing interfaces with uncomparable dynamic typeRuntime panic on ==Know the underlying type, or use reflect.DeepEqual carefully
Large struct passed by value repeatedlyCopy overhead on every callUse 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.

This note is part of the Digital Garden — a collection of connected, evolving thoughts.