Module 1 · Lesson 3 · ~30 min read

Pointers and Value Semantics

Go uses pointers where Python/JS use references-everywhere and C uses pointers-with-arithmetic. It's a middle path. Understanding when you have a copy vs a reference is the single most important thing for writing correct Go, and it's the single biggest source of bugs when Python/JS people start writing Go.

Pass by value. Always.

Go passes everything by value. When you call a function, the argument is copied. No exceptions.

"But what about slices, maps, channels?" They're also passed by value — it's just that the value is a small descriptor (a few words) that contains a pointer to the underlying data. Copying the descriptor gives you a new descriptor pointing to the same backing store. So mutations to the backing store are visible to both copies, but reassigning the descriptor itself isn't.

func tryMutate(xs []int) {
    xs[0] = 99        // visible to caller — same backing array
    xs = append(xs, 4)  // MAY or may NOT be visible to caller (see below)
}

If append stays within capacity, the backing array grows in place and the caller's descriptor still points at it (but with the old len, so they don't see the new element). If append exceeds capacity, a new backing array is allocated and the callee's local descriptor gets updated — the caller's does not. This is why append idiomatically reassigns: xs = append(xs, x).

& and *

var x int = 42
p := &x           // p is *int, points to x
fmt.Println(*p)   // 42 — dereference
*p = 100          // writes through the pointer; x is now 100

That's all the pointer syntax. No pointer arithmetic. No pointer-to-pointer chains in practical code. The garbage collector handles lifetimes — you don't free, you don't leak from forgetting to free.

Value vs pointer receivers — the rule

A method defined with a value receiver:

func (c Counter) Inc() { c.n++ }  // operates on a COPY

…gets a copy of the struct every time it's called. Mutations are lost.

A method with a pointer receiver:

func (c *Counter) Inc() { c.n++ }  // operates on the original

…gets a pointer to the actual struct. Mutations stick.

When to pick pointer receiver

When to pick value receiver

Default

When unsure, use a pointer receiver. The overhead of an indirection is negligible, and you avoid the silent-drop-mutation trap. Canton-era codebases overwhelmingly use pointer receivers.

A concrete example that always confuses newcomers

type Config struct { Timeout int }

func (c Config) SetTimeout(t int) { c.Timeout = t }

func main() {
    cfg := Config{}
    cfg.SetTimeout(30)
    fmt.Println(cfg.Timeout)  // 0, not 30
}

Why? SetTimeout has a value receiver. Inside the method, c is a copy. The assignment mutates the copy. The original cfg never sees it. Pointer receiver fixes this: func (c *Config) SetTimeout(...).

If you're asking "why didn't the compiler catch this?" — because it's legal. Value receivers are useful, and the language lets you have them. The compiler can't know you intended the mutation to stick.

Memory step-through

Walk through the same program with value semantics vs pointer semantics. The boxes are stack frames and heap allocations. Arrows are pointers.

Step-through

Method sets — what's callable on what

A type T and its pointer type *T have different method sets:

Translation: if you have a pointer, you can call every method. If you have a value, you can only call value-receiver methods. In practice you rarely hit this — Go auto-addresses when you write c.Inc() on an addressable value — but it matters for interface satisfaction:

type Incrementer interface { Inc() }

func (c *Counter) Inc() { c.n++ }  // pointer receiver only

var _ Incrementer = &Counter{}        // OK — *Counter's method set has Inc
var _ Incrementer = Counter{}         // COMPILE ERROR — Counter's method set does not

You'll hit this when wiring up a struct to satisfy an interface. The fix is always to take the address (&Counter{}).

Escape analysis — why you don't worry about heap vs stack

When you do x := &SomeStruct{} and return it from a function, that allocation has to live past the function's stack frame. Go's compiler detects this (via escape analysis) and allocates the struct on the heap automatically. When it can prove the allocation doesn't escape, it stays on the stack.

You don't control this directly. You can inspect it with go build -gcflags='-m' if you care. For most Canton-adjacent code, the performance difference isn't worth thinking about. Write clear code; let the compiler place it.

Takeaways