Spectre
// PUBLISHED24.10.26
// TIME6 MINS
// TAGS
#GOLANG#CONCURRENCY#GOTCHA#DEBUGGING
// AUTHOR
Spectre Command

Go Gotcha #3: The sync.Mutex Copy Bug That Looks Fine Until It Isn't

Meta: "The golang mutex copy bug compiles clean, passes tests, then deadlocks in production. Here's what's happening and how go vet catches what the compiler misses."


T

he code compiles. The tests pass. You ship it. Three days later, a goroutine in production is blocked on a lock that nothing will ever release.

The golang mutex copy bug is in a category of its own: it requires no syntax error, no type mismatch, no obvious mistake. It's a struct passed by value instead of by pointer, and the mutex inside it gets copied along for the ride. From that point on, you have two independent mutexes where you thought you had one, and your synchronization guarantees are gone.


Why Copying a Mutex Breaks Everything

A sync.Mutex has internal state. When it's locked, that state records the fact that it's held. When you copy a struct containing a mutex, you copy that internal state at the moment of copying a snapshot of the mutex frozen in time.

If the mutex is unlocked at copy time, you now have two independent mutexes. They share no state. Goroutines using the original and goroutines using the copy are no longer synchronized with each other. They're guarding separate copies of data they think is the same data.

If the mutex is locked at copy time, the copy starts life already locked with no goroutine holding it. The only way to unlock a sync.Mutex is for the goroutine that locked it to call Unlock(). Since no goroutine locked the copy, nothing will ever unlock it. Any goroutine that calls Lock() on it will block forever.

Untitled
1type SafeCounter struct {
2 mu sync.Mutex
3 count int
4}
5
6func process(sc SafeCounter) { // copied by value mutex comes along
7 sc.mu.Lock()
8 sc.count++
9 sc.mu.Unlock()
10}
11
12func main() {
13 counter := SafeCounter{}
14 go process(counter) // counter is copied here sc.mu is independent
15 go process(counter) // another copy another independent mutex
16 // both goroutines think they're synchronized, neither is
17}

The two goroutines are each operating on their own copy of counter, with their own copy of the mutex. They can both modify count simultaneously. The mutex protects nothing.


The Three Ways This Gets Into Your Code

Passing a struct by value to a function or goroutine. The most common case. Someone writes go doWork(myStruct) instead of go doWork(&myStruct), and the mutex inside gets copied with it.

Assigning a struct to a new variable.

Untitled
1original := SafeCounter{}
2copy := original // mutex is copied here

copy.mu and original.mu are now independent. Locking one does nothing to protect the other. This one is easy to write and easy to miss in review.

Appending to a slice or using as a map value.

Untitled
1counters := []SafeCounter{}
2c := SafeCounter{}
3counters = append(counters, c) // c is copied into the slice

When a slice grows, it reallocates and copies its elements. If those elements contain mutexes, every reallocation copies them. Map values in Go are not addressable you can't take the address of myMap["key"] which means you can't safely store a struct with a mutex as a map value either.

The fix in all three cases is the same: use a pointer.

Untitled
1func process(sc *SafeCounter) { // pointer no copy, same mutex
2 sc.mu.Lock()
3 sc.count++
4 sc.mu.Unlock()
5}

What go vet Catches (and What It Doesn't)

The Go compiler does not catch mutex copies. This is a valid, compilable Go program with a latent deadlock hiding inside it, and the compiler will build it without complaint.

go vet catches it. The copylocks analyzer is built into the standard toolchain and flags any case where a type containing a lock is copied:

Untitled
1go vet ./...
2# output: process passes lock by value: SafeCounter contains sync.Mutex

Run this. Run it in CI. If it's not in your pipeline, add it today it takes about 30 seconds to set up and it catches a class of bug that otherwise only shows up as a production deadlock.

The limitation: go vet catches direct copies it can see statically. It won't catch copies that happen through reflection, unsafe pointer arithmetic, or serialization/deserialization paths. If you're marshaling a struct to JSON or passing it through an RPC boundary, the vet check doesn't protect you there.


The Part That Surprises Everyone: Unlocked Copies Still Break Things

Most engineers, when they first hear about this bug, assume the dangerous case is copying a locked mutex. That's the case that deadlocks, yes. But copying an unlocked mutex is also wrong, and it's the more common mistake in practice.

When both copies start unlocked, the code appears to work. Each goroutine acquires its own mutex, does its work, releases it. No deadlock. No panic. Tests pass.

What you've actually built is unsynchronized access to shared data, with a mutex providing false confidence. Two goroutines can enter their respective Lock()/Unlock() blocks simultaneously because they're locking different mutexes. If the data they're operating on is actually shared and the reason you added a mutex in the first place was that it is you now have a data race.

The race detector catches this where go vet can't:

Untitled
1go test -race ./...

Run both. go vet catches the copy. The race detector catches the resulting unsynchronized access. Together they cover the cases where the copy is obvious and where it only shows up at runtime.


What We Found in a Background Job Processor

A job processing service had a Worker struct that contained a mutex to protect its internal queue. The service spawned workers like this:

Untitled
1for i := 0; i < numWorkers; i++ {
2 w := Worker{id: i, queue: sharedQueue}
3 go w.Run() // Worker passed by value to the method mutex copied
4}

Except Run() was defined on a value receiver:

Untitled
1func (w Worker) Run() { // value receiver copies Worker, including the mutex
2 // ...
3}

At low concurrency, the bug was invisible. Workers mostly processed jobs sequentially and the race condition multiple goroutines accessing the queue without real synchronization was rare enough to not trigger. Under load during a batch processing run, the race started manifesting as duplicate job processing and occasional panics from concurrent map writes.

The fix: change func (w Worker) Run() to func (w *Worker) Run(). One character. Weeks of debugging to get there.

go vet would have flagged it immediately. It wasn't in the CI pipeline.


FAQ

Q: How do I know if my struct is safe to copy? A: If it contains a sync.Mutex, sync.RWMutex, sync.WaitGroup, sync.Cond, or any type that embeds these, it's not safe to copy after first use. The go vet copylocks checker will flag most of these automatically. As a general rule: if a struct has a method that modifies internal state, use a pointer receiver, not a value receiver.

Q: What if I need to copy a struct that contains a mutex? A: You don't copy it you copy the data the mutex protects, then let the new copy manage its own mutex independently. Create a constructor that initializes a fresh mutex and copies only the non-mutex fields. Never directly assign or pass a struct-with-mutex by value.

Q: Value receivers are idiomatic in Go. Does this mean I should always use pointer receivers? A: Use pointer receivers whenever the struct contains a mutex, a large amount of data (to avoid copy overhead), or when the method needs to modify the receiver. Value receivers are fine for small, immutable structs. The idiomatic guidance isn't "always use value receivers" it's "use value receivers when copying is safe and cheap."

Q: Will go vet catch mutex copies in all cases? A: No. It catches copies it can see statically in your source code. It won't catch copies through reflect, unsafe, encoding packages, or RPC serialization. For those paths, code review and the -race detector are your main tools.

Q: I inherited a codebase and I'm not sure if this bug exists. Where do I start? A: Run go vet ./... and go test -race ./... across the entire codebase. The first finds potential copies. The second surfaces actual races at runtime. Then grep for value receivers on types that contain sync primitives grep -r "func (w Worker)" . for each relevant type. Any value receiver on a mutex-containing struct is suspicious.


The compiler won't save you here. go vet will, and so will the race detector but only if you run them. Most CI pipelines test. Fewer vet. Almost none run the race detector against their full test suite.

At SpectreDev this is part of every Go codebase review: vet, race detector, and a manual check of receiver types on any struct containing a sync primitive. It takes ten minutes and has caught this exact bug more than once.


Internal links used:

  • Mutex throughput post ``
  • nil interface gotcha post ``

External links used:

  • None all tooling is stdlib or standard Go toolchain

Word count: ~1,230

// END_OF_LOGSPECTRE_SYSTEMS_V1

Is your current architecture slowing you down?

Stop guessing where the bottlenecks are. We partner with founders and CTOs to audit technical debt and execute zero-downtime system rewrites.

Book an Architecture Audit