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."

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." Quick story:

  • The stack is fast memory used for function calls - every call grows it; every return shrinks it. Limited size.
  • The heap is general-purpose memory that outlives any single function call. Garbage-collected (Go cleans up things you stop using).

Go decides for you. The compiler does escape analysis to figure out whether a value can safely live on the stack (faster) or must live on the heap (longer-lived). You almost never have to think about it.

The only thing you need to know now: taking the address of something with & may or may not cause it to live on the heap. Don't worry about it. Write clear code; let the compiler decide.

The full picture lands when you read "Go runtime" content later. For now: trust the language.

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