Skip to main content

Value vs Pointer Receivers in Go: Method Sets, Interfaces, and Performance

Published: April 24, 2026 Updated: August 28, 2026 Larry Qu 8 min read

Choosing between value and pointer receivers is one of the most consequential API decisions you make for a Go struct. It affects mutation semantics, interface satisfaction, memory allocation, and concurrency safety. Most explanations stop at “pointers can mutate, values can’t” — but the deeper story is method sets and how they interact with interfaces.

Quick Reference

// Value receiver — method gets a copy of the struct
func (p Point) Distance() float64 {
    return math.Sqrt(p.X*p.X + p.Y*p.Y)
}

// Pointer receiver — method gets a pointer to the original
func (p *Point) Scale(factor float64) {
    p.X *= factor
    p.Y *= factor
}

Behavior: What Each Receiver Gets

type Counter struct {
    count int
}

// Value receiver: operates on a copy — cannot mutate original
func (c Counter) ValueGet() int {
    return c.count
}

func (c Counter) ValueBrokenIncrement() {
    c.count++ // Increments the COPY, original unchanged
}

// Pointer receiver: operates on original — can mutate
func (c *Counter) PointerIncrement() {
    c.count++ // Increments the ORIGINAL
}

func main() {
    c := Counter{count: 0}

    c.ValueBrokenIncrement()
    fmt.Println(c.count) // 0 — copy was incremented, not c

    c.PointerIncrement()
    fmt.Println(c.count) // 1 — original incremented

    // Go auto-dereferences: c.PointerIncrement() is sugar for (&c).PointerIncrement()
    // This only works on addressable values (variables, not map elements)
}

The Method Set Rules (Critical)

This is the part most explanations miss. In Go, the set of methods available to a type depends on whether you have a value or a pointer:

Type Methods available
T Only methods with receiver T
*T Methods with receiver T and *T

A pointer type has a superset of the value type’s methods.

type Door struct{ locked bool }

func (d Door) IsLocked() bool  { return d.locked }  // value receiver
func (d *Door) Lock()          { d.locked = true }   // pointer receiver
func (d *Door) Unlock()        { d.locked = false }  // pointer receiver

func main() {
    d := Door{}          // value
    pd := &Door{}        // pointer

    d.IsLocked()   // OK: value receiver on value
    pd.IsLocked()  // OK: value receiver — *T has all T methods
    pd.Lock()      // OK: pointer receiver on pointer

    d.Lock()       // OK at call site: Go auto-takes address → (&d).Lock()
    // BUT: see interface section below — this auto-addressing has limits
}

Interface Satisfaction: The Real Constraint

The method set rules have a direct consequence for interface implementation:

type Locker interface {
    Lock()
    Unlock()
}

type Door struct{ locked bool }

func (d *Door) Lock()   { d.locked = true }
func (d *Door) Unlock() { d.locked = false }

func main() {
    var l Locker

    l = &Door{} // OK: *Door has Lock and Unlock
    l.Lock()

    // l = Door{} // COMPILE ERROR: Door does not implement Locker
    //            // (only *Door has Lock/Unlock methods)
}

Rule: If any method required by an interface uses a pointer receiver, only the pointer type satisfies that interface — not the value type.

// This compiles fine — IsLocked has value receiver
type ReadOnlyLocker interface {
    IsLocked() bool
}

var rl ReadOnlyLocker = Door{}  // OK: Door has IsLocked() bool
var rl2 ReadOnlyLocker = &Door{} // Also OK: *Door has all Door methods

The Interface Compliance Pattern

A compile-time check that your type implements an interface:

// Placed at package level — causes a compile error if Door doesn't implement Locker
var _ Locker = (*Door)(nil)

This is idiomatic Go — you get an early, clear error rather than a confusing “does not implement” at the call site.

Auto-Addressing and Its Limits

Go sometimes auto-takes the address of a variable to call a pointer receiver method:

d := Door{}
d.Lock() // Go silently converts to (&d).Lock()

But this only works on addressable values. Map elements are not addressable:

type Door struct{ locked bool }
func (d *Door) Lock() { d.locked = true }

m := map[string]Door{"front": {}}
// m["front"].Lock() // COMPILE ERROR: cannot take address of map index expression

// Fix 1: Store pointers in the map
m2 := map[string]*Door{"front": {}}
m2["front"].Lock() // OK

// Fix 2: Copy, modify, put back
d := m["front"]
d.Lock()
m["front"] = d

The same issue appears with returned values:

func getDoor() Door { return Door{} }
// getDoor().Lock() // COMPILE ERROR: cannot take address of getDoor()
// The return value is a temporary — not addressable

Mutation and Concurrency Safety

Pointer receivers increase the risk of data races because multiple callers share the same underlying memory:

type SafeCounter struct {
    mu    sync.Mutex
    count int
}

// Must use pointer receiver — mutex cannot be copied
func (c *SafeCounter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

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

// WRONG: value receiver copies the mutex — creates independent lock
func (c SafeCounter) BadIncrement() {
    c.mu.Lock() // locks a COPY of the mutex, not the original
    defer c.mu.Unlock()
    c.count++
}

Rule: If a struct contains a mutex or other sync primitive (sync.Mutex, sync.RWMutex, sync.WaitGroup), always use pointer receivers — value receivers would copy the sync primitive, breaking its synchronization guarantee.

Memory: Escape Analysis and Allocation

Pointer receivers don’t always reduce allocations. The Go compiler’s escape analysis determines whether a value lives on the stack or heap:

// This might escape to heap (accessed via pointer, survives function)
func newCounter() *Counter {
    c := Counter{} // may escape to heap
    return &c
}

// This stays on stack (value is copied out, Counter doesn't escape)
func computeDistance(p Point) float64 {
    return p.Distance() // value receiver — no escape
}

For small structs (≤ 2 words / 16 bytes on 64-bit), value receivers are often faster because:

  • No pointer indirection
  • Better cache locality
  • May avoid heap allocation entirely

For large structs, pointer receivers avoid copying:

// Large struct: 1000 bytes — value receiver copies 1000 bytes per call
type LargeConfig struct {
    Fields [125]int64 // 1000 bytes
}

func (c LargeConfig) ExpensiveCopy() { /* ... */ }   // copies 1000 bytes
func (c *LargeConfig) CheapPointer() { /* ... */ }  // copies 8 bytes (pointer)

To measure, use benchmarks:

go test -bench=. -benchmem -count=3

Practical Conventions

The Go community has converged on these rules (from Effective Go and the standard library):

Use pointer receiver when:

  1. The method needs to mutate the receiver
  2. The struct contains a sync primitive (mutex, cond, etc.)
  3. The struct is large (>4 words / 32 bytes) and copying is expensive
  4. The type is used as an interface and some methods are pointer receivers — be consistent

Use value receiver when:

  1. The type is a small, immutable value type (think time.Time, net.IP)
  2. The method only reads the receiver
  3. The type is a basic type or array with comparable behavior

Consistency rule (most important): if any method on a type uses a pointer receiver, use pointer receivers for all methods of that type. Mixed receivers confuse users and create interface compliance surprises.

// BAD: Mixed receivers — confusing
func (t Thing) Name() string { return t.name }  // value
func (t *Thing) SetName(s string) { t.name = s } // pointer

// GOOD: Consistent pointer receivers
func (t *Thing) Name() string { return t.name }
func (t *Thing) SetName(s string) { t.name = s }

Real-World Examples

HTTP Handler Pattern

// Handler struct with pointer receiver — common pattern
type UserHandler struct {
    db  *sql.DB
    log *slog.Logger
}

// All methods use pointer receiver for consistency
func (h *UserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    users, err := h.getUsers(r.Context())
    if err != nil {
        h.log.Error("get users failed", slog.Any("error", err))
        http.Error(w, "internal error", 500)
        return
    }
    json.NewEncoder(w).Encode(users)
}

func (h *UserHandler) getUsers(ctx context.Context) ([]User, error) {
    // ...
    return nil, nil
}

Value Type (Immutable Semantics)

// Point behaves like a value — value receivers make sense
type Point struct{ X, Y float64 }

func (p Point) Distance(q Point) float64 {
    dx, dy := p.X-q.X, p.Y-q.Y
    return math.Sqrt(dx*dx + dy*dy)
}

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

func (p Point) Scale(factor float64) Point {
    return Point{p.X * factor, p.Y * factor}
}

// Usage: immutable style — original is never modified
p := Point{3, 4}
q := p.Scale(2) // returns new Point, p unchanged

Builder Pattern (Pointer Chaining)

type QueryBuilder struct {
    table  string
    where  []string
    limit  int
}

func (q *QueryBuilder) Table(t string) *QueryBuilder {
    q.table = t
    return q // return pointer for chaining
}

func (q *QueryBuilder) Where(condition string) *QueryBuilder {
    q.where = append(q.where, condition)
    return q
}

func (q *QueryBuilder) Limit(n int) *QueryBuilder {
    q.limit = n
    return q
}

func (q *QueryBuilder) Build() string {
    query := "SELECT * FROM " + q.table
    if len(q.where) > 0 {
        query += " WHERE " + strings.Join(q.where, " AND ")
    }
    if q.limit > 0 {
        query += fmt.Sprintf(" LIMIT %d", q.limit)
    }
    return query
}

// Usage
query := (&QueryBuilder{}).
    Table("users").
    Where("age > 18").
    Where("active = true").
    Limit(10).
    Build()

Summary: Decision Checklist

Does the method mutate the receiver? → Pointer receiver Does the struct contain sync.Mutex/WaitGroup? → Pointer receiver (always) Is the struct large (>32 bytes)? → Pointer receiver Does any OTHER method use pointer receiver? → Pointer receiver (consistency) Is this a small immutable value type? → Value receiver Does the method only read? → Either (prefer consistency)

When in doubt: use pointer receivers. It’s easier to change from pointer to value later than vice versa, and pointer receivers give you more flexibility with interfaces.

Resources

Comments

👍 Was this article helpful?