- Book: The Complete Guide to Go Programming
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open a PR review on a Go service. The diff adds three new methods on Counter. Two have value receivers, one has a pointer receiver. Three files later, Cache is the same way: most methods on the value, a few on the pointer, and the file compiles.
Then a goroutine deadlocks in staging. Or a counter never increments. Or a benchmark on a 200-byte struct shows noticeably worse throughput than it had last week. The receiver style is the cause every time, and nobody flagged it in review.
Three cases force the receiver type. Outside those three, it is style.
Rule 1: Mutation needs a pointer
A value receiver is a copy. The method runs against that copy, mutates it, and discards it when it returns. The original is untouched. The Go specification spells this out under method sets: a method invocation on a value receiver works on a copy of the value.
Here is the bug that hides in plain sight:
package counter
type Counter struct {
n int
}
func (c Counter) Inc() {
c.n++
}
func (c Counter) Value() int {
return c.n
}
Call this in the obvious way:
c := Counter{}
c.Inc()
c.Inc()
c.Inc()
fmt.Println(c.Value()) // 0
Three increments, zero. Inc mutates a copy that goes out of scope on the return. The compiler is happy. The test that asserts Value() == 3 fails. This is the kind of bug new engineers can stare at for a while the first time they hit it.
The fix is one character:
func (c *Counter) Inc() {
c.n++
}
Now c.Inc() takes the address of c, and the increment lands on the original. Any method that needs to change the receiver's state needs a pointer receiver. A value receiver that "almost works" under one test will fail on the next refactor.
The corollary: if a method mutates state, give every method on that type a pointer receiver. Mixing pointer and value receivers on the same type confuses readers. The next person to satisfy an interface with your value will find half the method set lives on the pointer.
Rule 2: A struct with a sync primitive must use pointer receivers
Get this one wrong and it ships as a silent concurrency bug.
sync.Mutex, sync.RWMutex, sync.WaitGroup, sync.Once, sync.Cond, and sync/atomic types all share a property: copying them is a bug. The Go documentation says so explicitly. From the sync package docs: "Values containing the types defined in this package should not be copied."
When you copy one of these, you get a fresh, independent primitive. Two mutexes instead of one, each protecting nothing the other goroutine can see. The compiler accepts the copy. go vet will sometimes catch it. The runtime will not, because each individual lock is internally consistent. They just are not the same lock anymore.
Here is the bug:
package cache
import "sync"
type Cache struct {
mu sync.Mutex
m map[string]string
}
func (c Cache) Set(k, v string) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[k] = v
}
func (c Cache) Get(k string) string {
c.mu.Lock()
defer c.mu.Unlock()
return c.m[k]
}
Two goroutines call Set concurrently. Each goroutine receives its own copy of Cache. Each copy has its own sync.Mutex. A Go map is a reference type, so both copies of Cache point at the same underlying map — the data race is reachable while the lock is not shared. You can hit a fatal error: concurrent map writes panic, and when you do, the stack trace shows the lock as taken. That makes the bug look impossible until you realize each goroutine had its own lock.
Fix the receivers:
func (c *Cache) Set(k, v string) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[k] = v
}
func (c *Cache) Get(k string) string {
c.mu.Lock()
defer c.mu.Unlock()
return c.m[k]
}
Now both goroutines share the same *Cache and the same mutex; the lock works.
go vet ships with a copylocks analyzer that catches the obvious version of this. It will fire on func (c Cache) Set because Cache contains a sync.Mutex. Run it. CI should fail on a lock-copy warning. Teams that hit this bug in production are often skipping vet, or running it and ignoring its output.
The same rule applies to any type that embeds a sync primitive transitively. If your struct contains a struct that contains a sync.Mutex, the rule still holds. Pointer receivers, every time.
Rule 3: A large struct should use a pointer receiver to skip the copy
A value receiver copies the entire struct on every call. For a 16-byte struct, that copy is essentially free; on most platforms it lives in registers. A 2 KB struct with embedded slices and a few maps shovels 2 KB through the call on every invocation.
A heuristic many Go teams reach for: once a struct passes roughly 64 bytes, prefer a pointer receiver. This is community practice, not an official Go-team recommendation — 64 bytes happens to match a cache line on most amd64 hardware (arm64 is typically 64 bytes too, but Apple Silicon uses 128-byte lines on M-series cores), and it is also roughly where the copy stops fitting in a few registers and starts showing up on hot paths. Treat it as a starting threshold, not a measurement.
Two methods on the same struct, one taking a value receiver and one taking a pointer:
package frame
type Header struct {
ID [16]byte
Timestamp int64
Source [32]byte
Dest [32]byte
Flags uint32
Length uint32
}
func (h Header) Checksum() uint32 {
var sum uint32
for _, b := range h.ID {
sum += uint32(b)
}
return sum
}
func (h *Header) ChecksumPtr() uint32 {
var sum uint32
for _, b := range h.ID {
sum += uint32(b)
}
return sum
}
Header is 96 bytes. Calling Checksum() copies all 96 bytes through the call (registers and stack, depending on the Go 1.17+ register ABI). Calling ChecksumPtr() passes 8 bytes — the pointer — and dereferences it on access.
A microbenchmark on this kind of struct can show the pointer version winning, depending on inlining, the Go version, and the CPU. Run it on your own hardware before you commit to a number.
The other side of the coin: small structs do not need this treatment, and forcing them onto the heap with a pointer receiver can cost you. A value receiver on a 16-byte struct often lets escape analysis keep the value on the stack with no allocation at all. A pointer receiver complicates escape analysis, and the struct can spill to the heap. Run with go build -gcflags='-m' to see what escape analysis actually decided.
If you want a number to design against: under 64 bytes, value receivers are usually fine and sometimes faster; above 64 bytes, default to pointer. Around the boundary, benchmark before you decide.
When value receivers win
Once you remove the three forcing functions, what is left is a small struct, no sync primitive, no mutation. A time.Time, a money.Amount{Cents int64, Currency string}. For these, value receivers are the natural choice and idiomatic Go.
A few reasons to lean toward value receivers when the rules do not force a pointer:
- The method cannot accidentally mutate the receiver. The type behaves like a value, and callers can reason about it as one.
- Escape analysis often keeps the value on the stack, dodging an allocation entirely.
- The method set is the same on
Tand*T, which makes interface satisfaction simpler. A method onTis in the method set of bothTand*T; a method on*Tis only in the set of*T, so value receivers give you the wider set.
The Go style guide nudges in the same direction. From Go Code Review Comments on receiver type: if in doubt, use a pointer receiver. The wiki page is worth reading.
The vet check that will save you
Add go vet ./... to CI if it is not already there. The copylocks analyzer in the standard vet suite catches the worst version of the second rule, the one that ships a silent concurrency bug. It does not catch the missing-mutation bug from the first rule, because there is no general way to know whether a method intended to mutate. It does not catch the size threshold from the third rule, because that is a performance question, not a correctness one. Even on its own, the copylocks check pays for the runtime.
A short checklist for the next PR
Three questions, in order:
- Does this method change the receiver's state? If yes, pointer receiver.
- Does this struct contain a
sync.Mutex,sync.WaitGroup,sync.Once, an atomic counter, or anything that wraps one? If yes, every method gets a pointer receiver, no exceptions. - Is the struct larger than roughly 64 bytes? If yes, pointer receiver, then benchmark to confirm.
If all three are no, value receiver is fine. Mix value and pointer receivers on the same type only when you have a written reason for it.
If this saved you a debugging session
The receiver question is one of a few small Go decisions that look like style on the surface and turn out to be load-bearing for correctness. Escape analysis, method sets, interface satisfaction, the rules around copying sync primitives, and the way the runtime treats stack vs heap allocations are covered end to end in The Complete Guide to Go Programming. If you have ever lost an afternoon to a method that "compiled fine" and silently corrupted state, that is the section to read.
The companion book, Hexagonal Architecture in Go, takes the same care to the design layer: how to structure services so the boundaries between domain types, ports, and adapters are clear enough that receiver-type mistakes get caught at the seam instead of in production.

Top comments (0)