39. Pointer or value? Choosing deliberately

📖 Reading · 11 min
💡 Every code box below is live — edit it and hit Run.

Knowing how pointers work is the easy part. The question that comes up in every code review is when to use one. Go has clear answers.

Reason 1: you need to modify the original

package main

import "fmt"

type Counter struct{ N int }

func incValue(c Counter)   { c.N++ }
func incPointer(c *Counter) { c.N++ }

func main() {
    c := Counter{}

    incValue(c)
    fmt.Println("value param:", c.N)

    incPointer(&c)
    fmt.Println("pointer param:", c.N)
}

If the function's job is to change the thing, it takes a pointer. This is the main reason, and it's not a judgement call — the alternative simply doesn't work.

Reason 2: the value is large

Passing a struct copies every byte of it:

package main

import "fmt"

type Big struct {
    Data [1000]int
    Name string
}

func byValue(b Big) int   { return b.Data[0] }
func byPointer(b *Big) int { return b.Data[0] }

func main() {
    b := Big{Name: "big"}
    b.Data[0] = 42

    fmt.Println(byValue(b))
    fmt.Println(byPointer(&b))
    fmt.Println("copied per call:", len(b.Data)*8, "bytes vs 8 bytes for a pointer")
}

A [1000]int is 8 KB. byValue copies all of it on every call; byPointer copies one machine word.

The threshold in practice is lower than people expect but higher than they fear: a struct with a handful of fields is fine to copy — often faster than a pointer, since it stays on the stack and the CPU cache likes it. Somewhere around "several dozen bytes, called in a hot loop" the calculus flips. Don't guess in either direction; if it matters, benchmark it (module 12).

Reason 3: distinguishing "zero" from "not set"

A pointer can be nil, and sometimes that's exactly the information you need:

package main

import "fmt"

type Settings struct {
    Timeout int
    Debug   *bool
}

func describe(s Settings) {
    fmt.Print("timeout ", s.Timeout, ", debug ")
    if s.Debug == nil {
        fmt.Println("(not specified, defaulting to false)")
        return
    }
    fmt.Println(*s.Debug, "(explicitly set)")
}

func main() {
    yes := true
    no := false

    describe(Settings{Timeout: 30})
    describe(Settings{Timeout: 30, Debug: &no})
    describe(Settings{Timeout: 30, Debug: &yes})
}

With a plain bool, "the user set it to false" and "the user said nothing" are the same value. A *bool tells them apart — which is why config parsers and JSON APIs use pointer fields for genuinely optional values.

Use it sparingly. Every *bool field means a nil check at every use site.

When to prefer a value

Values are the better default more often than newcomers assume:

package main

import "fmt"

type Point struct{ X, Y int }

func (p Point) Add(q Point) Point {
    return Point{X: p.X + q.X, Y: p.Y + q.Y}
}

func (p Point) Scale(f int) Point {
    return Point{X: p.X * f, Y: p.Y * f}
}

func main() {
    a := Point{1, 2}
    b := Point{10, 20}

    fmt.Println(a.Add(b))
    fmt.Println(a.Add(b).Scale(2))
    fmt.Println("a is untouched:", a)
}

Small, immutable, arithmetic-like types read beautifully as values: no nil to check, safe to share between goroutines, and chainable because each method returns a new value. time.Time works exactly this way.

Rules of thumb:

use a value use a pointer
small struct (a few fields) the method or function mutates it
immutable / arithmetic type large struct, copied in a hot path
you want copy semantics the type contains a mutex or other non-copyable
no nil case makes sense "unset" must differ from "zero"

Never copy a struct containing a mutex

This one is a hard rule, not a preference:

package main

import (
    "fmt"
    "sync"
)

type SafeCounter struct {
    mu sync.Mutex
    n  int
}

func (c *SafeCounter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.n++
}

func (c *SafeCounter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.n
}

func main() {
    c := &SafeCounter{}

    var wg sync.WaitGroup
    for i := 0; i < 50; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            c.Inc()
        }()
    }
    wg.Wait()

    fmt.Println(c.Value())
}

Copying a sync.Mutex copies its lock state, so the copy and the original protect nothing. Every method takes *SafeCounter, and the value lives behind a pointer from the moment it's created. go vet catches accidental copies for you — which is why module 12 tells you to run it.

Consistency beats cleverness

Once a type uses pointer receivers, use pointers everywhere for it: in slices ([]*User), in maps (map[int]*User), as parameters, as results. Mixing forms in one codebase produces the "does not implement" errors from module 6 and the "range gives you a copy" bug from module 5.

package main

import "fmt"

type User struct {
    Name   string
    Visits int
}

func (u *User) Visit() { u.Visits++ }

func main() {
    users := []*User{
        {Name: "Ada"},
        {Name: "Grace"},
    }

    for _, u := range users {
        u.Visit()
        u.Visit()
    }

    byName := make(map[string]*User, len(users))
    for _, u := range users {
        byName[u.Name] = u
    }
    byName["Ada"].Visit()

    for _, u := range users {
        fmt.Printf("%s: %d visits\n", u.Name, u.Visits)
    }
}

for _, u := range users works here precisely because u is a copy of a pointer — the copy still points at the same User. That's the whole trick, and it's why []*T is so common in Go code.

Note also byName["Ada"].Visit() — a map of pointers lets you modify the value in place, which a map[string]User cannot do at all (map values aren't addressable; the compiler rejects byName["Ada"].Visits++).

Your turn

Complete applyDiscount so it reduces the price of every product in the slice by the given percentage, in place:

Widget 90
Gadget 45
package main

import "fmt"

type Product struct {
    Name  string
    Price int
}

// write applyDiscount(products []*Product, percent int)

func main() {
    products := []*Product{
        {Name: "Widget", Price: 100},
        {Name: "Gadget", Price: 50},
    }

    applyDiscount(products, 10)

    for _, p := range products {
        fmt.Println(p.Name, p.Price)
    }
}
package main

import "fmt"

type Product struct {
    Name  string
    Price int
}

func applyDiscount(products []*Product, percent int) {
    for _, p := range products {
        p.Price = p.Price - p.Price*percent/100
    }
}

func main() {
    products := []*Product{
        {Name: "Widget", Price: 100},
        {Name: "Gadget", Price: 50},
    }

    applyDiscount(products, 10)

    for _, p := range products {
        fmt.Println(p.Name, p.Price)
    }
}

Next: the two allocation built-ins, new and make.