Tricky parts of Golang. The Mutex Copy Trap

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

ScenarioEffectFix
Value receiver with mutex fieldCopy per call, lock coordination brokenUse pointer receiver
Pass struct by value to functionCopy at call sitePass pointer
Assign struct with locked mutexCopy of locked state, deadlockNever copy; pass pointer
Range over slice of structsCopy per iterationRange over index, take &slice[i]
Goroutine closure captures by valueGoroutine gets its own lockClose 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.