Alright, let’s cut through the noise. Choosing between value and pointer receivers isn’t about memorizing a list of rules; it’s about understanding what you’re telling the compiler to do. Get this right, and your code is efficient and predictable. Get it wrong, and you’ll have a delightful time chasing bugs that make no sense. My favorite.

The core principle is embarrassingly simple: do you need to modify the receiver’s state? If yes, use a pointer (*T). If no, you probably want a value (T). But of course, it’s never that simple, is it? We have to talk about efficiency, method sets, and the dreaded implicit indirection.

The Golden Rule: Mutability

This is the big one. If your method needs to change the state of the receiver, you have no choice. You must use a pointer receiver. A value receiver gets a copy of the original value. Modifying it is like yelling at a photograph of your cat; the real cat remains blissfully unaware and continues knocking things off your desk.

type BankAccount struct {
    balance float64
}

// Value receiver: USELESS for modification.
func (b BankAccount) DepositUseless(amount float64) {
    b.balance += amount // This changes the copy, not the original.
}

// Pointer receiver: This is the way.
func (b *BankAccount) Deposit(amount float64) {
    b.balance += amount // This changes the actual struct.
}

func main() {
    account := BankAccount{balance: 100}
    account.DepositUseless(50)
    fmt.Println(account.balance) // Output: 100 (😑)

    account.Deposit(50)          // Go conveniently does (&account).Deposit(50) for us.
    fmt.Println(account.balance) // Output: 150
}

Notice how we called Deposit on account, not &account? That’s Go being helpful. The compiler automatically takes the address for you in this case. It’s syntactic sugar, and it’s the reason this whole system is bearable.

The Efficiency Rule: Don’t Copy Gigantic Structs

Even if your method is read-only, you might not want to use a value receiver. Why? Because every time you call a method with a value receiver, Go makes a full copy of the entire struct. For a small Point struct with two ints, fine. For a massive Order struct with 50 fields, a slice header, and a mutex, that’s a phenomenally wasteful operation.

type TinyStruct struct {
    a, b int
}

type MassiveStruct struct {
    data [10_000_000]float64 // Good luck copying this.
    // ... other fields
}

// Inefficient: Copies the entire 10MB array.
func (m MassiveStruct) Size() int {
    return len(m.data)
}

// Efficient: Copies only the pointer (a few bytes).
func (m *MassiveStruct) Size() int {
    return len(m.data)
}

The pointer receiver wins on efficiency for large structs, even for read-only methods. The memory and performance overhead of a copy dwarfs the tiny cost of a dereference.

The Consistency Rule: Don’t Be a Maniac

If any method on a type needs a pointer receiver, you should probably use pointer receivers for all methods on that type. This isn’t a hard compiler rule, it’s a sanity rule. Mixing receiver types within the same type is a recipe for confusion.

Imagine if some methods mutated the original and others mutated a copy. The cognitive load for anyone using your type (including future you) would be brutal. Just pick one. For types that aren’t immutable, the choice is almost always pointers.

The Method Sets Rule: The Interface Killer

This is the most subtle and important gotcha. The type of receiver determines what implements what interface.

  • A type T has a method set that includes all methods declared with value receivers (func (t T) Method()).
  • A type *T has a method set that includes all methods declared with both value AND pointer receivers (func (t T) Method() and func (t *T) Method()).

Read that again. The pointer type *T is strictly more powerful than the value type T in terms of what interfaces it satisfies.

type Speaker interface {
    Speak() string
}

type Dog struct {
    Name string
}

// Method with a value receiver.
func (d Dog) Speak() string {
    return "Woof! My name is " + d.Name
}

func main() {
    var s1 Speaker

    dVal := Dog{Name: "Rover"}
    s1 = dVal  // This works. T has the Speak method.
    fmt.Println(s1.Speak())

    dPtr := &Dog{Name: "Fido"}
    s1 = dPtr  // This ALSO works! *T has the method set of T.
    fmt.Println(s1.Speak())

    // Now, let's add a pointer receiver method.
    func (d *Dog) Rename(newName string) {
        d.Name = newName
    }

    var s2 Speaker
    s2 = dVal  // ERROR NOW: dVal is type T, but the method set of T no longer contains all methods.
                // The method set of T is only [Speak]. The method set of *T is [Speak, Rename].
                // Therefore, T does not fully implement Speaker anymore? Wait, no...

    // Correction: Adding a pointer-receiver method DOES NOT remove the value-receiver method from T's method set.
    // T's method set is still [Speak]. *T's method set is [Speak, Rename].
    // So T still implements Speaker. The assignment s2 = dVal is still valid.
    // The common pitfall is the reverse:

    type Renamer interface {
        Rename(string)
    }

    var r Renamer
    r = dVal  // ERROR: type Dog does not implement Renamer (Rename method has pointer receiver)
    r = dPtr  // OK: *Dog implements Renamer
}

The pitfall is this: if you have an interface that requires a pointer-receiver method, only a pointer to your type (*T) will satisfy it. The plain value (T) will not. This is why consistency is key. If you even think you might need to satisfy an interface that could require mutation, use pointer receivers for everything on that type from the start. It saves a brutal refactor later.