One method on a mutex-holding Go type takes a value receiver while its other thirty take pointers — what goes wrong?
answer
- one method out of thirty
- the copy brings the lock with it
- the compiler never says a word
- vet has a check named for copying locks
basics
~20 sThat one method is handed a copy of the whole struct, mutex included. It locks the copy, so callers get no mutual exclusion, and anything it writes to a field is thrown away when it returns. go vet's copylocks check flags the copied lock; the fix is one receiver kind for the whole type.
solid answer
~50 sA value receiver copies the struct at every call. On a type holding a `sync.Mutex` that means two distinct failures at once: the method locks its private copy of the mutex, so it excludes nobody, and any field it assigns — a cached length, a counter — is written to the copy and discarded on return. The compiler is entirely silent; the symptom in production is a value that stubbornly refuses to update, or a data race under load. `go vet` catches the lock half through its `copylocks` check, which reports a method that passes a lock by value, and running `go vet ./...` in CI is the cheap enforcement. It does not catch the lost-write half on a type with no lock in it. The real rule this is teaching: pick one receiver kind for a type and use it in every method, so the thirty-first method written by a new contributor cannot be the odd one out.
code
go · 23 linestype Path struct {
mu sync.Mutex
pts []Vec2
length float64 // cached
}
func (p *Path) Add(v Vec2) {
p.mu.Lock()
defer p.mu.Unlock()
p.pts = append(p.pts, v)
}
// Value receiver: p, its slice header and its sync.Mutex are copies.
// go vet's copylocks check reports the lock passed by value.
func (p Path) Recompute() {
p.mu.Lock()
defer p.mu.Unlock()
var sum float64
for i := 1; i < len(p.pts); i++ {
sum += math.Hypot(p.pts[i].X-p.pts[i-1].X, p.pts[i].Y-p.pts[i-1].Y)
}
p.length = sum // written to the copy, then discarded
}go deeper
Know that a value receiver hands the method a copy of the whole struct, so a copied sync.Mutex and any field written inside that method do not reach the caller's value.
Explain both halves of the failure — a lock that excludes nobody and a write that is discarded — and name go vet's copylocks check as the tool that reports a lock passed by value.
Show how you find this in a live service: the symptom is a cached value that never updates or a race under load, and the diagnosis is reading the receiver on the one method that differs from its thirty siblings. Say how you would enforce it in CI and review.
Own the standard rather than the incident: decide receiver kind at the type level, make vet part of the required checks, and be able to argue why consistency-by-imitation is worth more here than per-method micro-optimisation.
## The setup Picture a units-and-geometry package. A `Path` type accumulates points, caches the total length it computed last, and guards both with a `sync.Mutex` because several goroutines append to it. Thirty methods take `*Path`. A new contributor adds the thirty-first, copies the shape of a method they read somewhere else, and writes `func (p Path) Recompute()`. It compiles. It passes review if nobody looks at the receiver. It ships. ## What the copy does A value receiver means the method's parameter is a **copy** of the value at the call site, made when the call happens. Every field is copied: the slice header, the cached float, and the `sync.Mutex`. Two things follow, and a strong answer names both. **The lock protects nothing.** `p.mu.Lock()` inside that method locks the mutex *in the copy*. No other goroutine can see that mutex, so the method excludes nobody, and every other method — locking the real one — excludes nobody from it. The mutual exclusion the type advertises has a hole in exactly one method. Worse, the copy may be taken while the original is locked, producing a copy whose mutex starts life in a locked state and whose behaviour is then whatever that half-state implies. **The write vanishes.** `p.length = sum` assigns to the copy's field. When the method returns, the copy is discarded and the caller's `Path` is untouched. The bug reads as "the cached length is never right" or "Recompute does nothing", and it survives a lot of debugging because the code inside the method is obviously correct. ## What catches it `go vet`'s **copylocks** analyzer exists for precisely this. It reports a method that passes a lock by value, naming the type and the `sync.Mutex` it contains, and it fires on the declaration rather than waiting for a bad run. Wiring `go vet ./...` into CI is the whole enforcement story for the lock half, and it is close to free. What vet will not do is catch the general case. Take the mutex out of the struct and the identical mistake — one value receiver among thirty pointers, silently dropping writes — compiles clean and vets clean. The only things that catch that are a test that asserts the mutation actually happened, and a reviewer who looks at the receiver. ## Why the convention is "one kind per type" This is why Go style asks a type to use a single receiver kind across all of its methods, rather than choosing per method by what each one happens to need. The decision belongs to the **type**: - Does it hold a lock, or any field that must not be copied? Pointer receivers, everywhere. - Does it carry mutable state that methods update in place? Pointer receivers, everywhere. - Is it a small immutable value — a `Vec2`, a `Meters`, a coordinate — whose methods return new values rather than mutating? Value receivers, everywhere, and the copy is the point. Once the type has an answer, the thirty-first method has no decision left to make. That is the real defence: a contributor adding one method to a type somebody else shaped copies the surrounding pattern, and if all thirty neighbours agree, the pattern is unmissable. Mixed kinds destroy that signal — with fifteen of each, there is nothing to copy. ## Reviewing for it Three habits, in order of cost: 1. **Run `go vet ./...` in CI.** It is the only automated line of defence, and it covers the most damaging variant. 2. **Keep a type's methods together** — one file, or contiguous in one file — so the prevailing receiver kind is visible while you write the new one. 3. **Read the receiver first in review.** On a pull request that adds one method to a large type, the receiver is the first thing to check and takes a second. And when you find the odd one out, the fix is almost never to make the method smarter about locking; it is to change the receiver to match the other thirty, then check whether anything relied on the accidental copy. ## The thing to say out loud A value receiver is not a milder pointer receiver. It is a copy instruction. On a type that contains a lock or mutable state, a single value receiver is a correctness bug that the compiler will never mention, and consistency across the type is the convention that makes that bug impossible to introduce by imitation.
- If the type contains no lock, does go vet still catch the odd value receiver?No. `copylocks` fires on copying types such as `sync.Mutex`, `sync.RWMutex` and `sync.WaitGroup`; a plain struct copied by a value receiver is perfectly legal and vets clean. A lost write on a lock-free type is found only by a test that asserts the mutation, or by a reviewer reading the receiver.
- How would you keep one receiver kind across a type as it grows past thirty methods?Decide once at the type level — any lock or mutable state means pointers everywhere — then make the decision visible: keep the type's methods together so a new one is written next to twenty-nine identical neighbours, run `go vet ./...` in CI, and make the receiver the first thing you read in review of a method addition.
- Is all-value ever the right choice for a whole type?Yes. A small immutable value such as a 2D vector or a unit-carrying float should take value receivers in every method, returning new values rather than mutating. Consistency means picking the kind that fits the type, not defaulting to pointers; the failure mode is mixing, not choosing value.
- The fix changed one receiver from a value to a pointer. What do you check before merging?Whether anything depended on the copy — a method that deliberately worked on a snapshot, or a call site passing a value obtained from a map or another non-addressable expression. Also whether the now-real mutation exposes a locking gap that the discarded copy was accidentally hiding.
It is like photocopying a locked door: the copy has its own lock, and turning that key keeps nobody out of the original room.
saying these in an interview costs you the question
- Says the compiler rejects mixing receiver kinds on one type
- Thinks locking inside a value-receiver method still protects the original
- Expects go vet to catch every lost write, lock or not
- Believes copying a struct copies the mutex's ownership safely
- Fixes it by adding more locking instead of changing the receiver