Go Methods: Value vs Pointer Receivers

Methods in Go are functions with a special receiver argument that appears between the `func` keyword and the method name. Unlike languages with class-based inheritance, Go attaches methods to types...

Key Insights

  • Value receivers operate on copies of structs and cannot modify the original, while pointer receivers work directly with the original struct and enable mutation—Go automatically handles dereferencing so you can call pointer methods on values and vice versa.
  • Choose pointer receivers for structs that need modification, are large (>64 bytes), or when consistency matters—once you use a pointer receiver on a type, use pointer receivers for all methods on that type.
  • The most common pitfall is mixing receiver types inconsistently on the same struct, which confuses interface implementations and creates unpredictable behavior, especially in concurrent code.

Introduction to Methods in Go

Methods in Go are functions with a special receiver argument that appears between the func keyword and the method name. Unlike languages with class-based inheritance, Go attaches methods to types through this receiver syntax. You can define methods with either value receivers or pointer receivers, and this choice fundamentally affects how your code behaves.

Here’s a basic example showing both receiver types:

type Rectangle struct {
    Width  float64
    Height float64
}

// Value receiver - operates on a copy
func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

// Pointer receiver - operates on the original
func (r *Rectangle) Scale(factor float64) {
    r.Width *= factor
    r.Height *= factor
}

The receiver (r Rectangle) is a value receiver, while (r *Rectangle) is a pointer receiver. This seemingly small difference has significant implications for performance, memory usage, and program correctness.

Value Receivers: How They Work

When you define a method with a value receiver, Go creates a copy of the struct every time you call that method. The method operates on this copy, not the original struct. Any modifications made inside the method are lost when the method returns.

Value receivers make sense for small, immutable types and read-only operations. They’re ideal when you want to guarantee that a method won’t modify the original struct, making your code more predictable and safer for concurrent use.

type Point struct {
    X, Y int
}

func (p Point) Distance() float64 {
    return math.Sqrt(float64(p.X*p.X + p.Y*p.Y))
}

func (p Point) Translate(dx, dy int) Point {
    p.X += dx  // Modifies the copy
    p.Y += dy  // Modifies the copy
    return p   // Returns the modified copy
}

func main() {
    original := Point{X: 3, Y: 4}
    fmt.Println(original.Distance()) // 5
    
    translated := original.Translate(1, 1)
    fmt.Println(original) // {3 4} - unchanged
    fmt.Println(translated) // {4 5} - new value
}

Notice that Translate modifies its receiver, but since it’s a value receiver, the original Point remains unchanged. The method returns a new Point with the modifications. This functional programming style is common with value receivers.

Pointer Receivers: How They Work

Pointer receivers give methods direct access to the original struct. When you call a method with a pointer receiver, Go passes the memory address of the struct, not a copy. This allows the method to modify the struct’s fields, and those changes persist after the method returns.

Use pointer receivers when you need to mutate state, when dealing with large structs where copying would be expensive, or when you need to handle nil receivers.

type Counter struct {
    count int
}

func (c *Counter) Increment() {
    c.count++
}

func (c *Counter) Reset() {
    c.count = 0
}

func (c *Counter) Value() int {
    return c.count
}

func main() {
    counter := Counter{}
    counter.Increment()
    counter.Increment()
    fmt.Println(counter.Value()) // 2
    
    counter.Reset()
    fmt.Println(counter.Value()) // 0
}

The Increment and Reset methods modify the original Counter struct. Even though we’re calling these methods on counter (a value, not a pointer), Go automatically takes the address when calling pointer receiver methods.

Key Differences and Memory Implications

The fundamental difference is copying versus referencing. Value receivers create copies; pointer receivers work with the original. This affects both performance and behavior.

For small structs (typically under 64 bytes), copying is cheap and value receivers are fine. For larger structs, the copying overhead becomes significant. Consider a struct with many fields or embedded data:

type LargeStruct struct {
    Data [1000]int
    // ... more fields
}

// Bad: copies 8KB+ on every call
func (ls LargeStruct) ProcessValue() {
    // ...
}

// Good: passes 8-byte pointer
func (ls *LargeStruct) ProcessPointer() {
    // ...
}

Go automatically handles dereferencing and referencing, which makes the syntax convenient but can hide what’s happening:

type User struct {
    Name  string
    Email string
}

func (u *User) UpdateEmail(email string) {
    u.Email = email
}

func (u User) GetName() string {
    return u.Name
}

func main() {
    user := User{Name: "Alice", Email: "alice@example.com"}
    
    // Go automatically takes &user for pointer receiver
    user.UpdateEmail("alice@newdomain.com")
    
    userPtr := &user
    // Go automatically dereferences for value receiver
    name := userPtr.GetName()
    
    fmt.Printf("%p\n", &user)    // Address of original
    fmt.Printf("%p\n", userPtr)  // Same address
}

Decision Guidelines and Best Practices

Choose pointer receivers when:

  • The method needs to modify the receiver
  • The struct is large (rule of thumb: >64 bytes)
  • The struct contains a mutex or other non-copyable fields
  • You want consistency with other methods on the type

Choose value receivers when:

  • The struct is small and cheap to copy
  • The method is read-only
  • The type is immutable by design
  • The receiver is a map, function, or channel (already reference types)

Critical rule: Be consistent. Once you use a pointer receiver for any method on a type, use pointer receivers for all methods on that type. Mixing creates confusion and can cause subtle bugs with interface implementations.

type Cache struct {
    mu    sync.RWMutex
    items map[string]interface{}
}

// All methods use pointer receivers for consistency
func (c *Cache) Set(key string, value interface{}) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[key] = value
}

func (c *Cache) Get(key string) (interface{}, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    val, ok := c.items[key]
    return val, ok
}

func (c *Cache) Delete(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    delete(c.items, key)
}

// Even read-only methods use pointer receivers for consistency
func (c *Cache) Size() int {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return len(c.items)
}

Notice that even Get and Size, which don’t modify the cache’s data, use pointer receivers. This is necessary because the mutex must be the same instance across all method calls. Additionally, maintaining consistency makes the code more predictable.

Common Pitfalls and Gotchas

The most common mistake is mixing receiver types inconsistently. This becomes problematic with interfaces:

type Saver interface {
    Save() error
}

type Document struct {
    Content string
}

func (d *Document) Save() error {
    // Save logic
    return nil
}

func ProcessDocument(s Saver) {
    s.Save()
}

func main() {
    doc := Document{Content: "Hello"}
    // This won't compile: Document doesn't implement Saver
    // ProcessDocument(doc)
    
    // This works: *Document implements Saver
    ProcessDocument(&doc)
}

Only *Document implements Saver, not Document. If you need both to work, you must use a value receiver.

Another gotcha involves nil receivers. Pointer receiver methods can be called on nil pointers, which can be useful but also dangerous:

type Tree struct {
    Value int
    Left  *Tree
    Right *Tree
}

func (t *Tree) Sum() int {
    if t == nil {
        return 0
    }
    return t.Value + t.Left.Sum() + t.Right.Sum()
}

func main() {
    var tree *Tree
    fmt.Println(tree.Sum()) // 0 - works because we check for nil
}

Range loop variables create another trap:

type Item struct {
    ID int
}

func (i *Item) Process() {
    fmt.Printf("Processing item %d\n", i.ID)
}

func main() {
    items := []Item{{1}, {2}, {3}}
    
    // Wrong: all goroutines reference the same loop variable
    for _, item := range items {
        go item.Process() // Might print wrong IDs
    }
    
    // Correct: create a copy
    for _, item := range items {
        item := item // Shadow the loop variable
        go item.Process()
    }
    
    time.Sleep(time.Second)
}

In Go 1.22+, this specific issue is fixed, but understanding the underlying problem remains important for working with older codebases.

Conclusion

Choosing between value and pointer receivers is one of the most important decisions when designing Go types. Value receivers provide safety through immutability and are perfect for small, read-only types. Pointer receivers enable mutation and are essential for large structs or types that manage state.

Follow these rules: use pointer receivers for mutability and large structs, use value receivers for small immutable types, and above all, be consistent within each type. When in doubt, prefer pointer receivers—they’re more flexible and avoid the performance penalty of copying large structs.

The Go compiler and runtime do significant work to make receiver syntax convenient, automatically taking addresses and dereferencing as needed. Understanding what happens beneath this convenience helps you write more efficient, correct code and avoid subtle bugs in concurrent programs or interface implementations.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.