
Tricky parts of Golang. The Mutex Copy Trap
You copy a struct to pass it to a helper function. The mutex inside is now two independent locks protecting the same data. Your race detector says nothing. Your tests pass. Production disagrees — intermittently, and badly.
This article explains why copying a mutex silently breaks your invariants, how to catch it before it ships, and the patterns that prevent it from ever being written.
The Trap
Here is a stripped-down version of a bug that appears regularly in Go codebases:
type Cache struct {
mu sync.Mutex
items map[string]string
}
func (c Cache) Set(key, value string) { // value receiver — copies the struct
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = value
}
Set is called on a value receiver. Go copies the entire Cache struct before entering the method — including the sync.Mutex field. The copy gets its own lock state, independent of the original. Two goroutines can now hold what they believe to be the same lock simultaneously, and neither knows about the other.
The data race is real, but it is not always visible. If c.items is a map (a reference type), both the original and the copy point to the same underlying map. The mutex on the copy does not protect the map in the original at all. You have the appearance of synchronisation with none of the substance.
Why sync.Mutex Cannot Be Copied
A sync.Mutex is a struct with internal state — a locked/unlocked flag and a waiter count — stored directly in its fields:
sync.Mutex memory layout:
┌──────────────┬───────────────────┐
│ state int32 │ sema uint32 │
└──────────────┴───────────────────┘
When you copy the struct, you copy those field values at that instant. If the mutex happened to be unlocked when copied, the copy starts life in an unlocked state too — so Lock() on the copy succeeds immediately, with no relationship to the original. If it was locked when copied (a rarer but nastier case), the copy starts life in a permanently locked state and your goroutine deadlocks.
The Go memory model makes no provision for the semantics of a mutex surviving a copy. The documentation for sync is explicit: a Mutex must not be copied after first use. The trap is that “first use” includes the moment a goroutine is spawned that might lock it — which is almost always before you copy.
Where This Bites You in Practice
Pattern 1: Value receiver on a method
The example above. The fix is always a pointer receiver:
func (c *Cache) Set(key, value string) { // pointer receiver — no copy
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = value
}
Any struct containing a mutex, a sync.WaitGroup, a sync.Cond, or a channel used for signalling should have all its methods on a pointer receiver. There are no exceptions.
Pattern 2: Passing a struct by value to a function
func process(c Cache) { // copies Cache, copies the mutex
c.mu.Lock()
defer c.mu.Unlock()
// ...
}
// called as:
process(myCache) // silent copy
The fix:
func process(c *Cache) {
c.mu.Lock()
defer c.mu.Unlock()
// ...
}
process(&myCache)
Pattern 3: Assigning a struct to a new variable
original := Cache{items: make(map[string]string)}
original.mu.Lock()
snapshot := original // copies a locked mutex — snapshot.mu is now permanently locked
This is the most dangerous variant. snapshot.mu is copied in a locked state. Any subsequent snapshot.mu.Lock() call will block forever. This surfaces as a deadlock that appears to have no cause — because the locking and the copy are on different lines, often far apart in the code.
Pattern 4: Range over a slice of structs
type Worker struct {
mu sync.Mutex
jobs []Job
}
workers := []Worker{ /* ... */ }
for _, w := range workers { // w is a copy
go func(w Worker) { // another copy
w.mu.Lock()
// ...
}(w)
}
Both the range variable and the goroutine argument copy the struct. Fix by ranging over indices:
for i := range workers {
go func(w *Worker) {
w.mu.Lock()
// ...
}(&workers[i])
}
How to Detect It
go vet
The standard toolchain ships a copylocks analyser that catches many forms of this bug:
go vet ./...
It will flag value receivers on types containing locks, and direct assignments of lock-containing structs. It does not catch every case — goroutine closures that capture by value are often missed — but it should be a non-negotiable part of CI.
staticcheck
Run staticcheck alongside go vet for broader concurrency and correctness checks. Mutex-copy violations themselves are caught by go vet’s copylocks analyzer — not by staticcheck’s SA2001, which flags empty critical sections after a lock.
staticcheck ./...
At code review
The heuristic is simple: any struct literal, function argument, or range variable that contains or transitively contains a sync.Mutex, sync.RWMutex, sync.WaitGroup, or sync.Cond must be passed as a pointer. If you see a value copy of such a struct anywhere in a diff, flag it.
The noCopy Sentinel
The standard library uses a clever trick to make go vet catch copies of types that must not be copied, even when there is no mutex in the struct directly:
type noCopy struct{}
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}
By embedding noCopy in your struct and implementing the sync.Locker interface on it (with no-op bodies), go vet’s copylocks analyser will treat your type as lock-containing and flag any copies. You can see this pattern in sync.WaitGroup, strings.Builder, and others in the standard library.
type SafeBuffer struct {
noCopy noCopy
mu sync.Mutex
buf []byte
}
Now go vet will catch SafeBuffer copies at the call site, even before any mutex is involved.
The Mental Model That Prevents the Bug
If a struct has a mutex, all of its methods must be on a pointer receiver, and it must only ever be passed as a pointer.
A mutex is not a value — it is a coordination point between goroutines. It only works as long as every goroutine that wants to participate in the coordination uses the same memory address. The moment you copy it, you create a second coordination point that nobody else knows about.
Treating all lock-containing structs as reference types — always addressed through pointers, never copied — is the single rule that prevents this entire class of bug.
Summary
| Scenario | Effect | Fix |
|---|---|---|
| Value receiver with mutex field | Copy per call, lock coordination broken | Use pointer receiver |
| Pass struct by value to function | Copy at call site | Pass pointer |
| Assign struct with locked mutex | Copy of locked state, deadlock | Never copy; pass pointer |
| Range over slice of structs | Copy per iteration | Range over index, take &slice[i] |
| Goroutine closure captures by value | Goroutine gets its own lock | Close over pointer |
The mutex copy trap is silent because Go’s type system allows copying any struct — there is no nocopy keyword, no move semantics, no compiler error. The only defences are go vet, staticcheck, and the discipline of treating mutex-containing structs as permanently reference types.
