Learn Go Series (#10) - Pointers, Memory, and Escape Analysis

Words
2861
Reading
13 min
Listen
Play
2h

Learn Go Series (#10) - Pointers, Memory, and Escape Analysis

go-banner.png

What will I learn

  • What a pointer is, and what & (address-of) and * (dereference) actually do;
  • How passing a pointer lets a function mutate the caller's value, and how Go auto-dereferences struct pointers;
  • new, nil pointers, and why dereferencing a nil pointer panics;
  • Why returning the address of a local variable is perfectly safe in Go (unlike C);
  • What escape analysis is -- how the compiler decides whether a value lives on the stack or the heap -- and how to see its decisions;
  • Why Go has no pointer arithmetic, and when to reach for a pointer at all.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Go distribution (1.27 or newer, from go.dev/dl) -- tested against Go 1.27;
  • Episodes 1-9, especially receivers (episode 8) where pointers first appeared;
  • The ambition to learn Go programming.

Difficulty

  • Beginner

Curriculum (of the Learn Go Series):

Learn Go Series (#10) - Pointers, Memory, and Escape Analysis

Pointers have a fearsome reputation from C, where they come with manual memory management and the ever-present risk of dangling references. Go keeps the useful part -- a value that refers to another value, so you can share and mutate it -- and removes almost all of the danger, because a garbage collector manages the memory and there is no pointer arithmetic to walk off the end of anything. Today we get comfortable with & and *, and then peek at the clever thing the compiler does behind the scenes: deciding, per value, whether it can live cheaply on the stack or must go on the heap. First, last episode's exercises.

Here is the mental model I want you to carry through the whole episode, because it is what makes Go pointers so much calmer than C pointers. A pointer is just a value that happens to be an address -- nothing more magical than that. You can copy it, store it in a struct, compare it to nil, hand it to a function. What you cannot do is the dangerous thing: you cannot add to it, subtract from it, or cast it into some other type's address to reinterpret raw bytes (not without the escape hatch of the unsafe package, which we deliberately leave for much later). Take away pointer arithmetic and let a garbage collector own the lifetime question, and the two classic C disasters -- walking off the end of an array, and using memory after it was freed -- simply cannot happen in ordinary Go. That is the deal Go strikes: you keep the one genuinely useful power of a pointer, shared mutable reference, and you pay for it with none of the usual fear. Having said that, let us clear the exercises and build the idea up from the two operators.

Solutions to Episode 9 Exercises

Exercise 1 -- a Stringer of your own. A named int type with a String() method:

package main

import "fmt"

type Duration int // seconds

func (d Duration) String() string {
    return fmt.Sprintf("%dm %ds", d/60, d%60)
}

func main() {
    fmt.Println(Duration(135)) // 2m 15s -- String() is used automatically
}

Exercise 2 -- write to two places. One function, two destinations, both io.Writer:

package main

import (
    "fmt"
    "io"
    "os"
    "strings"
    "time"
)

func logLine(w io.Writer, msg string) {
    fmt.Fprintf(w, "[%s] %s\n", time.Now().Format("15:04:05"), msg)
}

func main() {
    logLine(os.Stdout, "straight to the console")

    var buf strings.Builder
    logLine(&buf, "captured in a buffer")
    fmt.Print(buf.String())
}

Exercise 3 -- a tiny type switch:

package main

import "fmt"

func kind(v any) string {
    switch v.(type) {
    case int:
        return "integer"
    case string:
        return "text"
    case bool:
        return "boolean"
    default:
        return "unknown"
    }
}

func main() {
    fmt.Println(kind(1), kind("x"), kind(true), kind(3.14))
}

Now, pointers.

& and *: address-of and dereference

A pointer holds the memory address of another value. &x gives you the address of x (its type is *int if x is an int), and *p dereferences the pointer -- reading or writing the value it points at. Those two operators are the whole of pointer syntax in Go:

package main

import "fmt"

func main() {
    x := 42
    p := &x // p is a *int holding the address of x

    fmt.Println(*p) // 42 -- read through the pointer
    *p = 100        // write through the pointer
    fmt.Println(x)  // 100 -- x itself changed, because p pointed at it
}

p refers to the same storage as x, so writing *p = 100 changes x. A pointer is a value like any other -- you can copy it, compare it, put it in a struct -- it just happens to be an address. And the zero value of any pointer type is nil.

One thing that trips people up early is that the * character does double duty, and Go tells them apart by where it sits. In a type, *int means "pointer to an int" -- it is part of the type's name. In an expression, *p means "the value p points at" -- it is the dereference operation. So var p *int declares a pointer variable, while *p a line later reads through it. The & is simpler: it only ever means "take the address of this thing", and it only works on things that have an address (a variable, a struct field, a slice element -- so-called addressable values), which is why you cannot write &42 on a bare literal. Pointers are also typed all the way down: a *int and a *float64 are different, incompatible types, and the compiler will not let you mix them. That typing is exactly what stops the reinterpret-these-bytes-as-something-else tricks that make C pointers so hazardous. ;-)

Pointers let functions mutate

Go passes everything by value -- arguments are copied. So if you want a function to change the caller's variable, you pass a pointer to it, and the function writes through that pointer. This is exactly what a pointer receiver did last episode, now in plain function form:

package main

import "fmt"

func double(n *int) {
    *n *= 2 // modify the value at the address
}

func main() {
    x := 21
    double(&x) // pass the address, not a copy
    fmt.Println(x) // 42
}

Without the pointer, double would receive a copy of x, double the copy, and change nothing. With &x, it receives the address and doubles the original. For structs, Go adds a convenience: you write p.Field and it automatically dereferences, so you never need the noisy (*p).Field:

package main

import "fmt"

type Point struct{ X, Y int }

func moveRight(p *Point, dx int) {
    p.X += dx // Go reads this as (*p).X, automatically
}

func main() {
    p := &Point{X: 1, Y: 2} // &T{...} builds and takes the address at once
    moveRight(p, 5)
    fmt.Println(*p) // {6 2}
}

&Point{...} allocates a Point and gives you its address in one expression, and p.X works whether p is a Point or a *Point -- Go dereferences for you. This auto-dereference is why pointer-receiver methods read so naturally.

It is worth being honest about the cost here, because "pass a pointer" is not automatically the faster choice. Passing a value copies it; passing a pointer copies only the address (8 bytes on a 64-bit machine) but then every access has to hop through that address. For a small struct -- a Point of two ints, say -- copying the whole thing is often as cheap as, or cheaper than, chasing a pointer, and it keeps the value on the stack where it is nice and local. So the reason to pass a pointer is almost always about semantics, not speed: you pass a pointer because the function must be able to change the caller's value, or because the type is genuinely large and you want to avoid copying it around. If a function only reads its argument and the value is small, take it by value -- it is simpler, and it makes the "I do not mutate this" promise right there in the signature. We will sharpen this into a proper rule at the end of the episode.

new, nil pointers, and the panic to avoid

The built-in new(T) allocates a zeroed T and returns a *T. You will not use it often -- &T{} or a plain var is usually clearer -- but it is worth knowing. Far more important is the nil pointer: a pointer that points at nothing. Dereferencing one panics, so guard pointers that might be nil:

package main

import "fmt"

type Node struct {
    Value int
    Next  *Node
}

func main() {
    p := new(int) // allocate a zeroed int, get its address
    *p = 7
    fmt.Println(*p) // 7

    var head *Node // nil pointer: points at nothing
    fmt.Println("head is nil:", head == nil) // true
    // head.Value here would PANIC: invalid memory address / nil dereference

    head = &Node{Value: 1} // now it points at a real Node
    fmt.Println(head.Value, head.Next == nil) // 1 true
}

A nil-pointer dereference is Go's equivalent of a null-pointer crash, and it is one of the few run-time panics you will actually meet. The habit that avoids it: whenever a pointer can be nil (a linked-list Next, an optional field, a lookup that might fail), check for nil before you follow it.

That last idea -- a pointer that is allowed to be nil -- is actually one of the nicer things a pointer buys you, so do not think of nil as purely a hazard. A *int field can be in a state a plain int cannot: absent. A plain int is always some number, even if that number is a zero you did not mean; a *int can be nil to say "no value was ever set here", which is exactly what you want for an optional configuration field, or the Next of the final node in a list, or the result of a lookup that found nothing. The Node above uses it precisely this way: Next == nil is the end-of-list marker, no separate sentinel needed. The price is that you, and everyone who touches that pointer, must remember to check before dereferencing -- so keep the nilable pointers few and obvious, and guard them at the boundary where they enter your code rather than sprinkling if p != nil everywhere downstream.

Returning the address of a local is safe here

In C, returning the address of a local variable is a classic bug -- the local lives on the stack frame that disappears when the function returns, leaving a dangling pointer. In Go it is completely safe. The compiler notices the address outlives the function and allocates the value on the heap instead, and the garbage collector keeps it alive as long as anything references it:

package main

import "fmt"

// Returning &c is safe: Go moves c to the heap because its address escapes.
func newCounter() *int {
    c := 0
    return &c
}

func main() {
    p := newCounter()
    *p++
    *p++
    fmt.Println(*p) // 2 -- c is alive and well on the heap
}

c looks like a stack local, but because we return its address, the compiler quietly puts it on the heap so the pointer stays valid. You never think about it -- you just return &c and it works. That "quietly puts it on the heap" is the topic of the next section.

This single guarantee is worth pausing on, because it deletes an entire category of bug that C programmers spend years learning to avoid. In C, the rule "never return the address of a local" has to live in your head, and the day you forget it you get a dangling pointer that sometimes works (the old stack memory has not been overwritten yet) and sometimes corrupts your program in ways that are miserable to debug. Go moves that reasoning from your head into the compiler: it looks at whether the address of a value can still be reached after the function returns, and if it can, the value does not get freed with the stack frame -- it lives on until the last reference to it is gone. Constructors that return &Thing{...} are completely normal Go, written a thousand times a day, and this is why. You do not manage the lifetime; you just make sure something still points at the value while you need it, and the garbage collector handles the rest.

Escape analysis: stack or heap, decided for you

Every value your program creates lives either on the stack (cheap: allocated and freed automatically as functions enter and exit, no garbage-collector involvement) or the heap (managed by the GC). Go decides which, per value, using escape analysis: if the compiler can prove a value does not outlive the function that made it, it stays on the stack; if the value's reference escapes (returned, stored somewhere longer-lived, captured by a closure that outlives the call), it goes on the heap.

You do not control this directly, and mostly you should not care -- but you can see the decisions, which is a great way to build intuition. Build with the -m flag and the compiler prints what escaped:

go build -gcflags='-m' ./...
# ...prints lines like:
#   ./main.go:8:2: moved to heap: c
#   ./main.go:14:13: ... does not escape

The practical payoff is modest but real: fewer heap allocations means less garbage-collector work, which matters in hot paths. The everyday guidance is simply "write clear code and let the compiler decide" -- but when you profile a program later in the series and find allocation is a bottleneck, escape analysis output is exactly how you find which values are escaping and why. For now, just know the mechanism exists and is working for you on every build.

A couple of things surprise people when they first read -m output. First, escape analysis is not the same as "does this use a pointer". A value can escape without you ever writing & (for instance, storing something into an interface{} often forces it to the heap, because the interface has to hold a reference), and conversely a &local can stay on the stack if the compiler can prove the pointer never leaves the function. Second, it is a static analysis: the compiler decides at build time, based only on what it can prove from the code, so it is deliberately conservative. If it cannot be sure a value stays put, it plays safe and heap-allocates. That is exactly the trade you want -- correctness first, and the occasional avoidable allocation is a small price. The reason I show you the flag now, this early, is not so you start micro-optimising (please do not), but so the words "stack", "heap", and "escape" stop being folklore and become things you can actually look at whenever you are curious.

So when should you actually reach for a pointer?

With the mechanics in hand, here is the practical guidance -- the part the learning objectives promised. Do not reach for a pointer by reflex; reach for it for one of a small number of concrete reasons. One: mutation. If a function (or a method) must change the caller's value so the change is visible after it returns, it needs a pointer -- this is the receiver rule from episode 8 and the plain-function version we saw today, the same idea wearing two hats. Two: optionality. If a field or return value genuinely needs an "absent" state that the zero value cannot express, a nilable pointer says that honestly. Three: identity and sharing. If two parts of your program must refer to the same underlying value -- nodes in a linked list, entries in a cache, a shared configuration object -- pointers give you that shared reference; copies would drift apart. Four (and least often): a genuinely large struct you are passing around a lot, where copying it repeatedly shows up in a profile.

And when should you not? When the value is small and you are only reading it -- pass it by value, it is simpler and keeps the "I do not mutate this" promise visible in the signature. Note too that slices, maps, and channels already hold internal references (we saw this with slices in episode 5 and maps in episode 6), so you rarely need a pointer to a slice or map just to mutate its contents -- a common beginner reflex that adds noise for nothing. The honest default is: write the value version first, and introduce a pointer only when one of the four reasons above actually applies. Clear code that lets the compiler do the clever stuff beats clever code that fights it.

The same idea in Python

Python has no explicit pointers or &/* -- but it does have references, and mutability decides whether a function can change your value. A list (mutable) passed to a function can be modified in place; an int (immutable) cannot. Go makes the reference explicit with a pointer, so mutation is never a surprise of the type:

def double_all(nums):       # nums is a reference to the caller's list
    for i in range(len(nums)):
        nums[i] *= 2        # mutates in place -- caller sees it

def try_double(n):          # n is an int (immutable)
    n *= 2                  # rebinds locally -- caller sees nothing

xs = [1, 2, 3]
double_all(xs)
print(xs)                   # [2, 4, 6]

In Python, whether a function can mutate your value depends on the value's type (mutable vs immutable). In Go it depends on whether you passed a pointer -- explicit and uniform. And Go has no pointer arithmetic at all: you cannot do p + 1 to walk memory, which removes an entire class of C bugs while keeping pointers' one genuinely useful power, shared mutable reference.

Exercises

  1. Swap in place. Write func swap(a, b *int) that exchanges the two values its pointers refer to. Call it with &x, &y and print x and y before and after to confirm the originals were swapped.

  2. A linked list length. Using the Node struct from this episode (a Value int and a Next *Node), build a short list by hand and write func length(head *Node) int that walks Next pointers until it hits nil, counting nodes. Handle the empty list (nil head) correctly.

  3. See it escape. Write a tiny program with one function that returns &SomeStruct{} and another that creates a struct and only uses it locally. Build with go build -gcflags='-m' and read the output: identify which value "moved to heap" and which "does not escape", and explain why in a comment.

What we learned

  • A pointer holds another value's address; &x takes the address, *p dereferences it to read or write the pointed-at value;
  • Go passes everything by value (arguments are copied), so you pass a pointer when a function must mutate the caller's variable; for structs, p.Field auto-dereferences;
  • new(T) allocates a zeroed T and returns *T (rarely needed); a nil pointer points at nothing, and dereferencing it panics -- so guard pointers that can be nil;
  • Returning the address of a local is safe in Go: the compiler moves escaping values to the heap and the GC keeps them alive -- no dangling pointers;
  • Escape analysis decides per value whether it lives on the cheap stack or the GC-managed heap; see its decisions with go build -gcflags='-m';
  • Go has no pointer arithmetic, removing a whole class of C bugs while keeping the one useful power of pointers: a shared, mutable reference.

Next episode we tackle the thing Go does differently from almost every mainstream language: error handling. No exceptions -- errors are ordinary values -- plus wrapping, errors.Is/As, sentinel errors, and the panic/recover escape hatch you should almost never use. See you there.

Thanks for reading, en tot de volgende keer! ;-)

scipio@scipio

Learn Go Series (#10) - Pointers, Memory, and Escape Analysis | Ecency