Skip to content

08 - Pointers and Memory

What this session is

About an hour. Pointers are the topic that scares beginners the most and turns out to be small once you actually meet them. By the end of this page you'll understand what a pointer is, when to use one, and why methods sometimes have *Type in their receiver.

We will go slowly. Pointers feel weird the first three times.

The problem

When you pass a value to a function, Go gives the function its own copy. Changes the function makes don't affect the caller.

package main

import "fmt"

type Counter struct {
    Value int
}

func increment(c Counter) {
    c.Value = c.Value + 1
}

func main() {
    counter := Counter{Value: 0}
    increment(counter)
    fmt.Println(counter.Value)   // 0 - not 1!
}

Type and run. Surprise: the counter is still 0. Why?

increment received its own copy of the counter. It bumped that copy. The original is untouched.

For numbers this is fine - most functions don't want to mutate their inputs. But sometimes you do. You want increment(counter) to actually increment the counter. That's what pointers are for.

What a pointer is

A pointer is a value that stores the address of another value. Instead of being "the counter," a pointer is "where the counter lives."

Two operators:

  • &x - read as "the address of x." Gives you a pointer to x.
  • *p - read as "the value pointed to by p." Goes from pointer to actual value. Called dereferencing.
package main

import "fmt"

func main() {
    x := 42
    p := &x                   // p is a pointer to x
    fmt.Println(x)            // 42
    fmt.Println(p)            // something like 0xc00001a098 - an address
    fmt.Println(*p)           // 42 - what p points to
    *p = 100                  // write through the pointer
    fmt.Println(x)            // 100 - x changed!
}

Type and run carefully. Notice the two important moves:

  • p := &x made p point at x. Now p knows where x lives.
  • *p = 100 followed the pointer back to x and wrote 100 into it. x is now 100.

The type of p is *int. Read it as "pointer to int." You'll see this notation in function signatures: func foo(n *int) means "foo takes a pointer to int."

Memory diagrams (the mental model)

Every variable in your program lives at some address in memory. Picture memory as a long row of numbered boxes, where each box can hold one value. A variable's name is a label on one of those boxes.

       x := 42                          y := &x

       address  contents                address  contents
       ─────────────────                ─────────────────
       0x1000  │  42  │  ◀── x          0x1000  │  42  │  ◀── x
       0x1008  │ ...  │                 0x1008  │ ...  │
                                        0x1100  │0x1000│  ◀── y  (holds the address of x)

Two simple rules cover all of pointer behavior:

  • &x - give me the address of the box labeled x.
  • *p - go to the address stored in p and use the contents of that box.

When the diagram changes:

*y = 100       // "go to the address in y, write 100 into that box"
//             address  contents
//             ─────────────────
//             0x1000  │ 100  │  ◀── x is now 100, because y pointed at it
//             0x1100  │0x1000│  ◀── y is unchanged (still holds the same address)

And the dangerous case - a pointer with no box to point at:

var p *int     // p's box contains the special value nil
*p = 5         // "go to nil and write..." -- crash. Runtime panic.

This picture is what's actually happening at the CPU level: variables are addresses; pointers store addresses; *p is the CPU instruction "load from the address in p." Everything else in this chapter is just polish on this base.

Try it: 1. Type and run:

x := 42
y := &x
z := &y           // z is a pointer to a pointer (a **int)
fmt.Println(x)    // 42
fmt.Println(*y)   // 42
fmt.Println(**z)  // 42 - follow z to y, follow y to x

  1. Now do **z = 99 and print x. What happened?

  2. fmt.Printf("%p\n", y) prints the actual address. Run it. Run the program twice - does the address change? (It can. Modern OSes randomize where programs land in memory for security; Go's runtime can also move stack data around as goroutine stacks grow.)

Fixing the counter

package main

import "fmt"

type Counter struct {
    Value int
}

func increment(c *Counter) {
    c.Value = c.Value + 1
}

func main() {
    counter := Counter{Value: 0}
    increment(&counter)
    fmt.Println(counter.Value)   // 1!
}

Two changes:

  • func increment(c *Counter) - the parameter is now a pointer to a Counter, not a Counter.
  • increment(&counter) - we pass the address of counter, not a copy.

Inside increment, c.Value = ... writes through the pointer to the original. The change sticks.

Notice: even though c is a pointer, we wrote c.Value, not (*c).Value. Go quietly does the dereferencing for field access. This is purely a convenience. Both forms work; the short one is what everyone writes.

Pointer receivers on methods (revisited from page 05)

Now the * on a method receiver makes sense:

func (c *Counter) Increment() {
    c.Value = c.Value + 1
}

This says: "the receiver is a pointer to a Counter, not a copy of one." When you call counter.Increment(), Go quietly passes the address of counter, the method mutates through it, the change sticks.

Compare to a value receiver:

func (c Counter) Increment() {
    c.Value = c.Value + 1   // mutates a copy; pointless
}

This compiles, runs, and does nothing visible. A classic beginner bug.

The rule of thumb (stronger version of what page 05 said):

  • If the method modifies the receiver's fields → use *Type.
  • If the receiver is a big struct (lots of fields) → use *Type even if you don't modify it. Saves the copy.
  • If the receiver is small and immutable → either works; pick one and be consistent across all methods on the type.

When in doubt, use *Type. Real Go code uses pointer receivers ~80% of the time.

nil pointers

A pointer can be nil - pointing at nothing. Dereferencing a nil pointer is a panic (crash).

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

You'll see this crash often when starting out. The fix is almost always "make sure the pointer is set to something before you use it." Either initialize it (p := &x) or check before dereferencing:

if p != nil {
    fmt.Println(*p)
}

When to use pointers

The honest answer:

  1. A method needs to mutate its receiver. Use a pointer receiver.

  2. You're passing a large struct around and don't want to copy it every time. Use a pointer.

  3. You explicitly want a "nullable" value - a thing that might or might not be set. Use a pointer; check nil.

  4. You're sharing state between functions, and they all need to see updates. Use a pointer.

Otherwise: pass values around. Copying is cheap for small things. Go's compiler is good at avoiding actual copies when it safely can.

What about memory? Stack vs heap?

You may have heard programmers talk about "stack" and "heap." Two regions of memory with different properties.

  • The stack is fast memory used for function calls. Each goroutine has its own stack. When a function is called, a new "frame" is pushed onto the stack holding its local variables. When the function returns, the frame is popped - the memory is reclaimed instantly, no garbage collector involved. Stacks are cheap and bounded.
  • The heap is general-purpose memory that outlives any single function call. Things on the heap stick around until nothing points to them anymore - then Go's garbage collector (GC) reclaims them, eventually. Heap allocations are slower than stack allocations because they have to talk to the allocator and the GC.

Go decides for each variable where it lives. The compiler does escape analysis at compile time. The rule, in one sentence:

A value stays on the stack only if the compiler can prove it never outlives the function that created it.

Everything else follows from that:

  • A local int you use and discard within the function → stack.
  • A local struct you take the address of and return to the caller → the caller might keep using it after your function returns, so the compiler moves it to the heap.
  • A local value passed to fmt.Println (which takes an interface{}) → the compiler often can't prove what Println does with it, so it errs safe and moves the value to the heap.

The phrase "escapes to the heap" is jargon for "the compiler couldn't prove this value's lifetime is bounded by the function, so it allocated on the heap instead."

Two consequences for everyday code:

  1. You almost never have to think about this. The compiler handles it; the GC handles cleanup. Write clear code first.

  2. Taking &x of a local variable is not automatically a heap allocation. Sometimes the compiler can still prove the pointer doesn't outlive the function, and the value stays on the stack. The & operator is a request, not a command.

You'll see how to ask the compiler what it decided in Going deeper.

Reference types: maps, slices, channels

A specific case worth calling out, because it confuses people.

When you do s := otherSlice or m := otherMap, you're not copying the data. You're copying a small header that contains a pointer to the underlying data. Both variables now share the same data.

m1 := map[string]int{"a": 1}
m2 := m1                  // m2 shares m1's hash table
m2["b"] = 2
fmt.Println(m1)           // map[a:1 b:2]  -- m1 sees b, because m1 and m2
                          //                  point at the same table

This is the same story you saw with slices on page 06. Maps, slices, channels (next page), and function values are reference types in Go: under the hood, they're really pointers to data structures. Copying a reference type copies the pointer-sized header, not the data.

Compare to value types (structs, arrays, primitive types like int):

type Box struct{ Count int }
b1 := Box{Count: 5}
b2 := b1
b2.Count = 99
fmt.Println(b1.Count)     // 5  -- b1 is independent

b2 := b1 copied the struct's fields - they're entirely separate values now.

The trick: when you see func foo(m map[string]int), that function can modify your map even though it isn't *map[string]int. The map header it received is a copy of the header, but the header still points at your table. Writes through it modify your table. The same applies to slices and channels.

This is why "slices and maps are passed by reference" is the way people talk about it, even though technically Go always passes by value - the value in question is itself a pointer.

Try it: 1. Type the m1/m2 example. Confirm both see the change.

  1. Try the Box example. Confirm they're independent.

  2. Now write a function func clear(m map[string]int) that does for k := range m { delete(m, k) }. Call it on a map. The function received "a copy" - but the map is empty after the call. Why? (Same answer: it got a copy of the header; both headers point at the same table.)

Going deeper

Production Go performance work eventually requires understanding the runtime: escape analysis, the garbage collector, stack growth, allocation pools. The material below is drawn from the Go team's own GC guide and runtime source. Links at the end.

Watch the compiler decide: -gcflags="-m"

Reminder of the one rule of escape analysis:

A value stays on the stack only if the compiler can prove it never outlives the function that created it.

You can ask the compiler to tell you, for any program, what escaped and why:

$ go build -gcflags="-m" main.go
./main.go:5:6: can inline counter
./main.go:11:8: &n escapes to heap: flow: ~r0 = &n
./main.go:11:8: moved to heap: n

"Moved to heap" means: this variable, which looks local, actually lives on the heap because the compiler couldn't prove its lifetime is bounded by the function.

Common reasons something escapes:

  1. You return a pointer to it. The caller might keep that pointer past your function's lifetime, so the value can't die when your function returns.

  2. You store it in an interface{} (or any interface). The compiler often can't see what the receiving code will do with it. fmt.Println(x) is the classic case - Println takes ...any, so most concrete values passed in escape.

  3. You store it in a longer-lived data structure - a global slice, a map outside the function, a struct field that itself escapes.

  4. You capture it in a closure that itself escapes the function.

For more detail, use -gcflags="-m=2" (level 2 is more verbose) or -gcflags="-m=3".

You won't tune this for normal code. You will eventually use it to chase a benchmark hot spot.

Try it:

package main

import "fmt"

func newPair(a, b int) *[2]int {
    arr := [2]int{a, b}        // does this stay on the stack?
    return &arr                // ...spoiler: no, because we return its address
}

func main() {
    p := newPair(1, 2)
    fmt.Println(p)
}
Build with go build -gcflags="-m" main.go. You should see something like moved to heap: arr. Now refactor newPair to return [2]int (value, not pointer). Rebuild with -m. Notice the difference.

The garbage collector: tricolor concurrent mark-and-sweep

Go's GC is a tricolor mark-and-sweep algorithm, and it's mostly concurrent - it runs while your program runs, with only brief stop-the-world (STW) pauses.

What "mark-and-sweep" means:

  1. Mark phase. Starting from "roots" (local variables on goroutine stacks, globals), the GC follows every pointer it finds, marking each reachable object as "live."
  2. Sweep phase. Memory that didn't get marked is dead - nothing points at it anymore. The GC makes that memory available for new allocations.

What "tricolor" means: during marking, every object is one of three colors.

  • White: not yet known to be live (may be dead).
  • Grey: known to be live, but its pointer fields haven't been scanned yet.
  • Black: known to be live, AND its pointer fields have been scanned.

The GC starts with all objects white. It colors the roots grey. It picks a grey object, marks it black, and colors every white object it points to grey. Repeat until no grey objects remain. Anything still white at the end is dead.

This algorithm works concurrently with the running program - but with one catch: if your goroutine writes a pointer into an already-black object, the GC might miss it. To prevent this, Go uses write barriers: while the GC is in the mark phase, every pointer write goes through a tiny piece of compiler-inserted code that re-marks the involved objects as needed. This adds a small overhead during GC mark phases - typically a few percent of CPU.

The GC cycle, simplified:

[sweep previous garbage] → [off] → [mark live objects] → [sweep new garbage] → [off] → ...
                                    ▲▲ STW              ▲▲ STW
                                    pause to start     pause to transition
                                    mark phase         mark → sweep

The two STW pauses are the only time your goroutines stop. Both are proportional to GOMAXPROCS, not to heap size. On a typical server, total pause time per cycle is well under a millisecond, even with multi-gigabyte heaps. That's Go's published guarantee.

The assist mechanism: if your program is allocating very fast and the background GC can't keep up, the GC asks for help. Whenever an allocating goroutine needs to allocate, it does a tiny bit of marking work first - proportional to how much it just allocated. This is "GC assist." It's the runtime's natural backpressure: allocate faster than the GC can mark, and your own goroutines have to help pay the bill.

The GC uses a target of 25% of CPU for mark work. So on a 4-core machine, one core is doing GC work during the mark phase. If your program is hammering allocation, GC assist temporarily uses more.

You'll see the assist if you profile a heavy-allocation program: runtime.gcAssistAlloc shows up in CPU profiles. More than ~5% of CPU in assist means your program is allocating faster than GC can keep up.

Try it:

package main

import (
    "fmt"
    "runtime"
    "time"
)

func main() {
    for i := 0; i < 5; i++ {
        // Allocate ~50MB of pointer-heavy garbage.
        objs := make([]*[1024]int, 50_000)
        for j := range objs {
            objs[j] = &[1024]int{}
        }
        runtime.GC()                 // force a GC cycle
        var m runtime.MemStats
        runtime.ReadMemStats(&m)
        fmt.Printf("cycle %d: heap=%d MB, gc count=%d, total pause=%v\n",
            i, m.HeapAlloc/1024/1024, m.NumGC, time.Duration(m.PauseTotalNs))
        objs = nil
    }
}
Run it. Notice the total pause time. On most machines it's a few hundred microseconds even for these multi-megabyte allocations. That's the concurrent GC at work.

GOGC and GOMEMLIMIT: tuning the GC

Two environment variables (and runtime API equivalents) control GC behavior.

GOGC sets how aggressively the GC runs, as a percentage of live heap. The formula from the GC guide:

Target heap size = live_heap + (live_heap + GC_roots) × GOGC / 100
  • GOGC=100 (the default): next GC kicks in when the heap doubles past the live size. ("100% headroom.")
  • GOGC=50: GC runs more often; heap stays smaller (less memory, more CPU on GC).
  • GOGC=200: GC runs less often; heap grows larger (more memory, less CPU on GC).
  • GOGC=off: disable GC entirely. Don't do this in production.

GOMEMLIMIT (Go 1.19+) sets a soft upper bound on total memory the Go runtime uses. The GC tries to stay under this limit by running more often as the heap grows. It's "soft" because if your program is genuinely allocating faster than the GC can keep up, exceeding the limit briefly is preferred to letting the GC pin 100% of CPU - the limiter caps GC at ~50% CPU over 2 × GOMAXPROCS-second windows.

GOMEMLIMIT=2GiB go run main.go

In production, the recommended setup from the Go guide:

  • Set GOGC=100 (default; don't tune unless you have profiles showing why).
  • Set GOMEMLIMIT to ~95% of your container's memory limit. If your container has 1 GiB, GOMEMLIMIT=950MiB. The headroom is for the runtime, stacks, and non-Go memory.
  • Profile with go tool pprof before tuning further.

Try it: Run the allocation program above with GOGC=50 (then GOGC=200). Notice how many more (or fewer) GCs happen, and what the heap peaks at.

sync.Pool for hot allocations - the implementation, not just the API

Suppose your HTTP handler builds a 4 KB buffer for every request. Each allocation pressures the GC. sync.Pool lets you reuse them:

var bufPool = sync.Pool{
    New: func() any { return make([]byte, 0, 4096) },
}

func handle(w http.ResponseWriter, r *http.Request) {
    buf := bufPool.Get().([]byte)[:0]   // grab one, reset length
    defer bufPool.Put(buf)
    // ... use buf ...
}

That looks simple, but what's actually happening underneath is worth knowing because it shapes how to use it well.

Per-P local pools. Each P (Go's logical processor - you'll meet these on page 09) has its own local pool. Get and Put on the current P don't touch any locks - they're as fast as a slice push/pop. This is what makes sync.Pool actually fast at scale; if it took a global lock, contention would destroy its purpose.

Victim cache and GC interaction. This is the surprising part. When a GC cycle starts, the runtime does:

  1. The contents of each P's local pool are moved to a per-P victim cache.
  2. The local pool is emptied.

If you call Get after this and the local pool is empty, the runtime first checks the victim cache; if found, the object survives one more cycle. Anything still in the victim cache at the next GC is discarded.

The consequence: objects you put in a pool live for at most 1–2 GC cycles before being discarded. A pool is not storage. It's a brief opportunity to reuse before GC reclaims.

Two surprises that follow:

  1. A Get can return a freshly-allocated object (via your New function) even if you just Put something - the GC may have cleared the pool between your calls. Don't assume any Get reuses anything.
  2. Get returns whatever was put in last; it may still contain old data. Always reset the returned value (buf[:0] for a []byte, zero the struct fields, etc.) before using it.

Don't reach for sync.Pool preemptively. It pays off only when allocation profiles show GC pressure is real and the pooled object is genuinely hot.

Try it: Take any short-lived allocation in a hot loop. Run it twice - once with direct allocation, once with sync.Pool. Use go test -bench=. -benchmem and look at allocs/op. If pool reuse drops allocations near zero (and the benchmark accurately reflects production), it's a win. If allocations stay similar, the pool isn't helping - production GC may already be clearing it between Puts.

Struct field order matters (and group your pointer fields first)

Go aligns struct fields on natural boundaries. A bool is 1 byte, but the next int64 field needs to start on an 8-byte boundary, leaving 7 bytes of padding:

type Sparse struct {
    A bool    // 1 byte
              // 7 bytes padding
    B int64   // 8 bytes
    C bool    // 1 byte
              // 7 bytes padding (end-of-struct alignment)
} // 24 bytes total

type Dense struct {
    B int64   // 8 bytes
    A bool    // 1 byte
    C bool    // 1 byte
              // 6 bytes padding at the end
} // 16 bytes total

Dense packs the same fields into 16 bytes by grouping the wide fields first. For one struct, 8 bytes is nothing. For a slice of a million of them, it's 8 MB.

A second, related optimization from the GC guide: group pointer fields at the start of the struct. The GC walks pointer fields during marking. It stops scanning at the last pointer - anything after is non-pointer data the GC doesn't have to look at. Putting pointers up front lets the GC stop scanning earlier:

// Slightly faster to GC: pointers grouped at start.
type Node struct {
    Next  *Node          // pointer
    Prev  *Node          // pointer
    Owner *User          // pointer
    Value int
    Score float64
}

Neither of these is a code-style rule - readability comes first. But for structs allocated in huge quantities, the ordering matters.

Try it: Write a small program with []Sparse and []Dense of a million elements. Use runtime.ReadMemStats to compare HeapAlloc. The 8-byte difference per struct should show as 8 MB difference in heap size.

Stacks grow (corrected: 2 KB, not 8 KB)

Each goroutine starts with a 2 KB stack on modern Go. If your function calls go deeper than 2 KB worth of frames, the runtime detects this on entry to the next function, allocates a new bigger stack (typically double), copies the current stack into it, fixes up any internal pointers, and continues. You see nothing.

Compare to OS threads, which typically start with a fixed 1–2 MB stack. That's why "use lots of goroutines" actually works: a million goroutines might use ~2 GB of stack space; a million OS threads would use ~2 TB.

Stacks can grow up to 1 GB per goroutine by default before the runtime panics with "stack overflow." Deep recursion in Go doesn't blow up nearly as easily as in C or Java.

You'll never write code that touches this. But when someone says "goroutines are cheap because of growable stacks," now you know the actual numbers.

Try it: Write a deeply recursive function - say, mutual recursion that nests 100,000 deep. Run it. It works. Now try the same in any language with fixed-size threads. (Don't actually try this in production code; it's just for understanding.)

Reading a memory profile

When something feels slow, the next-level move is pprof. The fastest setup:

import _ "net/http/pprof"

func main() {
    go func() { http.ListenAndServe("localhost:6060", nil) }()
    // ... rest of your program ...
}

While the program runs:

$ go tool pprof http://localhost:6060/debug/pprof/heap
(pprof) top 10
(pprof) list YourFunctionName
(pprof) web

top shows which functions allocated the most memory. list shows allocation-by-line in a specific function. web opens an SVG graph in your browser.

For allocation profiling specifically:

$ go tool pprof -alloc_space http://localhost:6060/debug/pprof/allocs

This counts total allocations (good for finding wasteful code) rather than "currently allocated" memory (which is what heap shows).

You'll learn this in depth later. For now: know it exists, know how to start it, recognize the output when you see it.

Where this material came from

Exercise

In a new file account.go:

  1. Define a struct Account with two fields: Owner (string) and Balance (float64).

  2. Add a method Deposit(amount float64) that adds amount to Balance. Choose the right receiver type (value or pointer).

  3. Add a method Withdraw(amount float64) error that:

  4. Subtracts amount from Balance if Balance >= amount.
  5. Returns nil on success.
  6. Returns an error like "insufficient funds: have X, want Y" otherwise.

  7. In main:

    acct := Account{Owner: "Alice", Balance: 100}
    acct.Deposit(50)
    fmt.Println(acct.Balance)            // 150
    if err := acct.Withdraw(200); err != nil {
        fmt.Println("withdraw failed:", err)
    }
    acct.Withdraw(50)
    fmt.Println(acct.Balance)            // 100
    

  8. Make sure both Deposit and Withdraw actually mutate acct. (If they don't, you picked the wrong receiver type.)

What you might wonder

"Why don't I always use pointers, to be safe?" Two reasons. (1) Pointers can be nil, which means an extra failure mode. (2) Pointers point at shared state, which means accidental mutation surprises. Values are simpler when simpler will do.

"Pointers in C scared me. Are Go pointers like that?" A lot less scary. No pointer arithmetic (you can't p + 1 to "move along an array"). No manual free() - the garbage collector handles it. No casting between types. About 80% of what makes C pointers scary doesn't exist in Go.

"Is *Counter a different type from Counter?" Yes, but Go is forgiving. You can call value-receiver methods on a pointer and vice versa most of the time. The type is technically distinct; the syntax is convenient.

"What's a **Counter?" A pointer to a pointer. You'll see it rarely. Real-world reasons: a function that wants to replace what a pointer points to. Don't worry about it now; recognize it when you see it.

Done

You can now: - Explain what a pointer is: a value holding the address of another value. - Use &x to take an address and *p to dereference. - Choose between value and pointer receivers on methods (mutation → pointer). - Recognize and avoid nil pointer crashes. - Stop fearing pointers.

You've now learned every basic mechanic of Go the language. From here on, the pages are about putting these mechanics together: concurrency (using all of them at once across multiple things running in parallel), testing (verifying your code does what you think), packages (organizing it), and eventually reading and contributing to real projects.

Next page: how Go does "do many things at once" - goroutines and channels.

Next: Concurrency 10109-concurrency-101.md

Comments