Pointer vs value receivers

Use pointer receivers to modify data or save memory on large structs; use value receivers for small, read-only operations.

The vanishing update

You write a method to update a configuration struct. You call it, print the result, and stare at the screen. The changes never stuck. The original struct sits untouched, exactly as it was before the call. You spent twenty minutes debugging a missing assignment, only to realize the method signature took a copy instead of the original. This is the classic value receiver trap. Go makes it easy to attach functions to types, but the choice between passing a copy or passing a memory address changes everything.

Copy versus address

A receiver is the first parameter of a Go method. It tells the compiler which type the method belongs to. You can declare it as a plain type T or as a pointer *T. The difference is about what the method actually receives when it runs.

Think of a value receiver like a photocopy. When you call the method, Go prints a duplicate of the struct and hands it to the function. The function can read it, calculate things, or scribble all over it. When the function returns, the photocopy gets shredded. The original document on your desk never changes.

A pointer receiver is the original document with a sticky note attached. The sticky note holds a memory address. When you call the method, Go hands over the address. The function follows it straight to the original data. Any change you make writes directly to the source. When the function returns, the sticky note is discarded, but the document stays modified.

Go does not force you to manually take the address or dereference the pointer when calling methods. The compiler handles the indirection automatically. If you have a value and call a pointer method, Go quietly adds &. If you have a pointer and call a value method, Go quietly adds *. This convenience hides the mechanics, which is exactly why the trap exists.

Value receivers isolate state. Pointer receivers share it.

Minimal example

Here is the smallest working example that shows both behaviors side by side.

package main

import "fmt"

type Counter struct {
    count int
}

// Increment takes a pointer so the change survives the call.
func (c *Counter) Increment() {
    c.count++ // writes directly to the original struct
}

// Reset takes a value, so it modifies a temporary copy.
func (c Counter) Reset() {
    c.count = 0 // changes the copy, not the original
}

func main() {
    c := Counter{count: 5}
    c.Increment() // compiler automatically passes &c
    fmt.Println(c.count) // prints 6
    c.Reset()             // compiler automatically passes a copy of c
    fmt.Println(c.count) // prints 6, not 0
}

The Increment method mutates the struct because the receiver is *Counter. The Reset method looks like it should zero out the counter, but it only zeros out the temporary copy created for the method call. The original c in main stays at 6. The compiler does not warn you about this. It is a logical error, not a syntax error.

Value receivers are cheap for small data. Pointer receivers are necessary for mutation.

How the compiler bridges the gap

When the compiler sees a method call, it checks the receiver type against the method signature. If they match, it generates a direct call. If you call a pointer method on a value, the compiler verifies that the value is addressable. Local variables, struct fields, and slice elements are addressable. Constants and function return values are not. If you try to call a pointer method on a non-addressable value, the compiler rejects it with cannot call pointer method on T.

At runtime, a value receiver copies the entire struct onto the stack. A struct{ x int; y int } takes 8 bytes to copy. That is negligible. A struct{ data [1024]byte } takes a kilobyte. Copy that a thousand times in a loop and you start paying for cache misses and stack pressure. A pointer receiver copies only the address, which is 8 bytes on modern systems. The actual data stays where it was allocated.

The automatic indirection works both ways. If you hold a *Counter and call a value method, Go dereferences the pointer, copies the data, runs the method, and discards the copy. The method runs safely, but it cannot mutate the original. This symmetry is why Go code reads cleanly. You never write (&c).Increment() or (*ptr).String(). The language handles the plumbing.

Pointer receivers carry the original data. Value receivers carry a snapshot.

Realistic example

Real code rarely deals with single integers. It deals with configuration, database rows, or HTTP request payloads. Consider a service that loads a user profile, applies defaults, and formats it for an API response.

package main

import (
    "fmt"
    "time"
)

type Profile struct {
    Name     string
    Age      int
    Created  time.Time
    Verified bool
}

// ApplyDefaults mutates the profile in place.
func (p *Profile) ApplyDefaults() {
    if p.Name == "" { // checks original data
        p.Name = "Anonymous"
    }
    if p.Created.IsZero() { // zero time means not set
        p.Created = time.Now()
    }
}

// Summary returns a read-only description.
func (p Profile) Summary() string {
    status := "unverified"
    if p.Verified { // reads from the copy
        status = "verified"
    }
    return fmt.Sprintf("%s (%s)", p.Name, status)
}

func main() {
    u := Profile{Name: "Ada", Verified: true}
    u.ApplyDefaults() // modifies u directly
    fmt.Println(u.Summary()) // prints Ada (verified)
}

The ApplyDefaults method needs to change the struct, so it takes *Profile. The Summary method only reads fields, so it takes Profile. Even though Profile contains a time.Time and a few strings, it fits comfortably in a few dozen bytes. Copying it for a read-only method is safe and avoids accidental mutation. If another goroutine called Summary while ApplyDefaults was running, the copy guarantees that Summary sees a consistent snapshot. The pointer method would risk reading half-updated fields.

Go convention dictates that receiver names are one or two letters matching the type. You will see (p *Profile) or (u User), never (this *Profile) or (self Profile). Keep it short. The compiler does not care about the name, but every Go developer expects it. Run gofmt on save and let it enforce the spacing around the receiver. The community accepts the formatting rules because they eliminate style debates.

Read-only methods take values. Mutating methods take pointers.

Pitfalls and silent bugs

The most common mistake is assuming a value receiver can mutate the original. The compiler will not stop you. It will compile, run, and silently preserve the old state. You will waste time adding & to the call site, only to find it does not help. The fix is always in the method signature, not the call site.

Another trap involves slices, maps, and channels. These types are already reference-like under the hood. A slice header contains a pointer to an underlying array, a length, and a capacity. When you pass a slice to a value receiver, Go copies the header, not the array. Appending to the slice inside a value receiver modifies the local copy of the header. The original slice length stays the same. If you need to change the slice length or capacity, use a pointer receiver *[]int. Maps and channels are different. Their headers are always passed by reference internally, so value receivers work fine for both reading and writing map entries or sending on channels.

Nil pointers cause immediate panics. If you declare a method with a pointer receiver and call it on a nil pointer, the program crashes with runtime error: invalid memory address or nil pointer dereference. Value receivers never panic on nil because they operate on copies. If your method must handle uninitialized state, check for nil at the top or switch to a value receiver.

The compiler will also reject pointer methods on non-addressable values. If you try to call a pointer method on a map value or a function return, you get cannot call pointer method on T. The fix is to assign the value to a variable first, or change the method to a value receiver.

Interface satisfaction depends on the receiver type. A type T satisfies an interface if T has all the required methods. A type *T satisfies an interface if *T has all the required methods. If a method has a pointer receiver, only *T satisfies the interface. Plain T does not. This is why you sometimes see cannot use T value as I value: missing method X. The solution is either to change the method to a value receiver, or to pass a pointer to the interface.

Mutation requires a pointer. Copies require a value. Choose deliberately.

When to pick which

Use a value receiver when the method only reads fields and the struct is small enough to copy cheaply. Use a value receiver when you want to guarantee that the method cannot accidentally mutate the original data. Use a pointer receiver when the method must modify fields, append to slices, or update map entries. Use a pointer receiver when the struct is large and copying it would waste stack space or degrade cache performance. Use a pointer receiver when you need to share a single instance across multiple method calls without duplicating state. Use plain functions instead of methods when the type does not logically own the behavior.

Where to go next