Day 8 — Pointers & escape analysis

Updated

July 30, 2026

Day 8 — Pointers & escape analysis

Stage I · ~3h (theory-heavy)
Goal: Use pointers deliberately—not fearfully—and read escape analysis (-gcflags=-m) well enough to explain stack vs heap placement of your Day 7-style types.

Note

Go is garbage collected. You do not free. Escape analysis decides whether a variable can live on the stack or must be heap-allocated. Your job is clear ownership and correct mutability; the compiler handles most placement.

Why this day exists

Pointers show up in three conversations that people conflate:

  1. Correctness: mutation, optional values, shared identity
  2. Performance: avoid giant copies; sometimes increase heap pressure
  3. APIs: method sets, interfaces, JSON null

Without a model, learners either spray * everywhere or panic at the first *T. Day 8 separates necessity from superstition, then shows the compiler’s notes.


Theory 1 — Addresses and dereferences

x := 10
p := &x  // p has type *int; points to x
fmt.Println(*p) // 10
*p = 20
fmt.Println(x)  // 20
Expression Meaning
&x Address of x (type *T if x is T)
*p Dereference pointer p
*T Type: pointer to T

Zero value is nil

var p *int
fmt.Println(p == nil) // true
// *p // panic: nil pointer dereference

Always ensure non-nil before dereference, or document that a method accepts nil receivers.

new and composite literals

p := new(int) // *int, points to zero int
*p = 3

q := &Item{SKU: "A1"} // same idea for structs — more idiomatic

Prefer &T{...} for structs; new(T) is rare but valid.


Theory 2 — Why take a pointer?

1. Mutate the caller’s value

func inc(n *int) {
    *n++
}

2. Avoid copying large structs

func (c *HugeConfig) Validate() error { /* ... */ }

A multi-kilobyte config should not be copied on every method call.

3. Express identity / shared state

type Conn struct{ /* ... */ }

func (s *Server) handle(c *Conn) {
    // same connection object everywhere
}

4. Optional values

func maxAge(a *int) string {
    if a == nil {
        return "unspecified"
    }
    return fmt.Sprintf("%d", *a)
}

Alternative later: option types / sql.NullInt64 patterns—but *T is common in APIs.

When values are better

  • Small structs (time.Time is a value type by design)
  • Immutable snapshots
  • Map keys (pointers as keys compare by address, which is usually wrong for “same content”)
  • Reducing GC pressure when escape would force heap allocation of many tiny objects

Theory 3 — Pointers to pointers (rare)

func setIfNil(pp **int, v int) {
    if *pp == nil {
        x := v
        *pp = &x
    }
}

Almost never needed in application code. If you want a function to replace a slice header or map variable for the caller, prefer returning the new value:

func add(s []int, v int) []int { return append(s, v) }

Theory 4 — Escape analysis (the mental model)

Stack allocation

  • Cheap
  • Lifetime tied to function activation
  • No GC work for that object after return

Heap allocation

  • Required when the compiler cannot prove the object dies before the function returns
  • GC must track it
  • Still automatic—no manual free

What forces escape (typical)

Pattern Why it escapes
Returning &local Caller holds reference past return
Assigning to interface that outlives Interface may store pointer
Sending pointer on channel Concurrent lifetime unknown
Storing in slice/map that escapes Shared structure outlives frame
Calling unknown function with *T Compiler conservatively assumes retention

Exact rules evolve with the compiler; read the output, do not memorize a myth list as law.

How to read -gcflags=-m

go build -gcflags='-m' .
# more detail:
go build -gcflags='-m -m' .

Sample messages:

./main.go:12:6: moved to heap: x
./main.go:20:9: &Item{...} escapes to heap
./main.go:33:18: inlining call to fmt.Println
Phrase Rough meaning
moved to heap Variable cannot stay on stack
escapes to heap Expression’s result may be heap-allocated
does not escape Stays stack-friendly (good for hot paths)
inlining call Call folded; often enables better analysis

Escape analysis messages are hints, not performance guarantees. Measure with benchmarks later (Stage VII).


Theory 5 — Common pointer pitfalls

Copying a mutex / copiable-looking struct

type Safe struct {
    mu sync.Mutex
    n  int
}

func (s Safe) IncWrong() { // value receiver copies mutex!
    s.mu.Lock()
    s.n++
    s.mu.Unlock()
}

sync.Mutex must not be copied after first use. Use pointer receivers for types containing mutexes.

Returning pointer to loop variable (historical)

Since Go 1.22, per-iteration variables make this safer; still prefer clear code:

var out []*int
for i := 0; i < 3; i++ {
    i := i
    out = append(out, &i)
}

Slice of pointers vs slice of values

items := []Item{{SKU: "a"}, {SKU: "b"}}
var ptrs []*Item
for i := range items {
    ptrs = append(ptrs, &items[i]) // OK: address of slice element
}

If you append to items later and reallocate, old pointers may dangle logically (point at old array). Prefer owning []*Item from creation if identity matters.


Theory 6 — Pointers and interfaces (preview bridge)

type Stringer interface {
    String() string
}

type P struct{ X int }

func (p *P) String() string { return fmt.Sprint(p.X) }

// var s Stringer = P{} // error: P does not implement Stringer
var s Stringer = &P{X: 1} // OK

Method sets differ for T and *T. Day 11 is the full story; today connect it to receiver choice.


Worked examples bank

Example A — Mutation clarity

package main

import "fmt"

type Point struct{ X, Y int }

func moveValue(p Point, dx, dy int) Point {
    p.X += dx
    p.Y += dy
    return p
}

func movePtr(p *Point, dx, dy int) {
    p.X += dx
    p.Y += dy
}

func main() {
    p := Point{1, 2}
    p = moveValue(p, 1, 1)
    movePtr(&p, 1, 1)
    fmt.Println(p)
}

Example B — Optional config field

type Options struct {
    TimeoutMS *int
    Verbose   bool
}

func effectiveTimeout(o Options, def int) int {
    if o.TimeoutMS == nil {
        return def
    }
    return *o.TimeoutMS
}

Example C — Escape demo program

package main

type Node struct {
    Value int
    Next  *Node
}

func stackCandidate() int {
    x := 10
    return x // x need not escape
}

func heapForced() *int {
    x := 10
    return &x // x escapes
}

func linked() *Node {
    n := &Node{Value: 1}
    n.Next = &Node{Value: 2}
    return n
}

Build with -gcflags=-m and annotate each line in a comment with what you saw.

Example D — Large struct: value vs pointer method

type Big struct {
    // pretend many fields
    Data [1024]byte
    N    int
}

func (b Big) CountValue() int  { return b.N }
func (b *Big) CountPtr() int   { return b.N }

Compare escape/inlining notes when calling both in a loop (micro). Prefer clarity; do not optimize blindly.

Example E — Constructor returning pointer

func NewNode(v int) *Node {
    return &Node{Value: v} // typically heap
}

This is normal and good. Escape is not a failure.


Labs

Suggested workspace: ~/lab/90daysofx/go/day08

Lab 1 — Port Day 7 types and measure

mkdir -p ~/lab/90daysofx/go/day08
cd ~/lab/90daysofx/go/day08
go mod init example.com/day08

Copy or reimplement a simplified Item / Store from Day 7.

go build -gcflags='-m' . 2> escape.txt

In escape.txt (or a journal), highlight:

  1. Something that does not escape
  2. Something that escapes to heap
  3. One line you could change (value vs pointer) and how the message changes

Lab 2 — Intentional nil safety

Implement a method on *Item that is safe when the receiver is nil. Call it on a nil pointer and on a real pointer. Contrast with a method that panics on nil.

Lab 3 — Optional pointer fields

Add an optional *int discount percent to a purchase calculator. Nil means no discount. Show JSON-less CLI flags parsing "none" vs a number into *int.

Lab 4 — Prove mutex copy danger (optional, careful)

Create a type with sync.Mutex, call a value-receiver lock method from two sequential calls and explain why this is wrong for concurrent use. Do not need multi-goroutine code yet—just document the copy.


Common gotchas

Gotcha Fix
Nil dereference Check or guarantee non-nil
Pointers for everything “for speed” Measure; extra heap can be slower
Ignoring method set (T vs *T) Match receivers to interface needs
Copying structs containing sync types Pointers + no copy
Fear of heap escape Escaping is normal for shared objects
Using pointer map keys for equal content Use values or canonical IDs
Dangling mental model from C GC keeps pointed memory alive

Checkpoint

  • & / * / *T vocabulary solid
  • Three good reasons to use a pointer
  • Read -gcflags=-m output and explained two lines
  • Nil-safe method demonstrated
  • Did not conclude “heap = bad”
  • Connected pointer receivers to future interfaces

Commit

git add .
git commit -m "day08: pointers + escape analysis notes"

Tomorrow

Day 9 — Packages & layout: multi-package modules, exported names, internal/, and a layout that scales past a single main.go without inventing a framework.