Fix "panic: sync: unlock of unlocked mutex" in Go

intermediate๐Ÿ”ท Go2026-05-14| Go 1.13+, Linux / macOS / Windows, any program using sync.Mutex or sync.RWMutex

Error Message

panic: sync: unlock of unlocked mutex
#go#mutex#concurrency#sync#panic

What just happened?

Your program crashed with:

panic: sync: unlock of unlocked mutex

goroutine 1 [running]:
sync.throw2({0x5a3b2c?, 0x0?})
        /usr/local/go/src/sync/mutex.go:35 +0x5c
sync.(*Mutex).unlockSlow(0xc000014090, 0xffffffff)
        /usr/local/go/src/sync/mutex.go:220 +0x38
sync.(*Mutex).Unlock(...)
        /usr/local/go/src/sync/mutex.go:193
main.main()
        /home/user/myapp/main.go:18 +0x5c

This crash is unrecoverable. The moment you call Unlock() on a sync.Mutex that's already unlocked, the mutex's internal state flips negative, the runtime catches it, and the goroutine dies โ€” no second chances. You can't wrap this in a recover() and move on.

The three ways this breaks

Most cases fall into one of these patterns:

1. Double Unlock

var mu sync.Mutex
mu.Lock()
mu.Unlock()
mu.Unlock() // panic here โ€” already unlocked

2. Unlock inside a loop when Lock was outside

var mu sync.Mutex
mu.Lock()
for i := 0; i < 3; i++ {
    doWork()
    mu.Unlock() // first iteration: fine. second: panic.
}

3. defer + manual Unlock firing twice

func process(mu *sync.Mutex) {
    mu.Lock()
    defer mu.Unlock() // queued on the defer stack

    // ... some code ...
    mu.Unlock() // explicit early unlock โ€” fine so far
    // function returns โ†’ defer fires again โ†’ panic
}

Why Go doesn't just ignore it

sync.Mutex has no owner concept and isn't re-entrant. It tracks one bit: locked or unlocked. Calling Unlock() on an already-unlocked mutex is always a programmer mistake โ€” there's no ambiguous case where Go could safely continue. So it doesn't try.

The same applies to sync.RWMutex, which has two flavors of the same problem:

panic: sync: unlock of unlocked mutex       // RLock/RUnlock imbalance
panic: sync: RUnlock of unlocked RWMutex    // RWMutex-specific variant

Find the imbalance first

Start with the race detector. It won't catch this exact panic โ€” it's not a data race โ€” but double-unlock bugs often come with neighbors:

go run -race main.go
# or
go test -race ./...

Then track every Lock/Unlock call on the suspect mutex:

grep -n 'mu\.Unlock\|mu\.Lock' yourfile.go

Count them. Every Lock gets exactly one Unlock โ€” across every code path, including early returns and error branches. A single path where the count is off is enough to crash the whole program.

Fixes that actually stick

Rule 1 โ€” defer immediately after Lock, and nowhere else

Pick one style per function. Don't hedge. The cleanest pattern:

func safeProcess(mu *sync.Mutex) {
    mu.Lock()
    defer mu.Unlock() // fires once, when the function exits

    doWork()
    // no other mu.Unlock() calls in this function โ€” ever
}

Rule 2 โ€” need an early unlock? Drop defer entirely

func processWithEarlyUnlock(mu *sync.Mutex, data []byte) error {
    mu.Lock()
    snapshot := copyData(data)
    mu.Unlock() // explicit โ€” no defer in sight

    // work on snapshot without holding the lock
    return process(snapshot)
}

Rule 3 โ€” Lock and Unlock must live at the same loop level

// WRONG โ€” Lock once, Unlock many times
mu.Lock()
for _, item := range items {
    process(item)
    mu.Unlock() // crashes after the first iteration
}

// RIGHT โ€” paired per iteration
for _, item := range items {
    mu.Lock()
    process(item)
    mu.Unlock()
}

Rule 4 โ€” wrap complex state in a struct

When a mutex guards multiple operations, hide it inside a type. Each method becomes a tiny, auditable Lock/Unlock pair:

type SafeCounter struct {
    mu    sync.Mutex
    count int
}

func (c *SafeCounter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

func (c *SafeCounter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

Small methods mean small blast radius. A 5-line method with one Lock/Unlock pair is nearly impossible to get wrong.

Fixing the defer + manual Unlock double-fire

// BROKEN
func broken(mu *sync.Mutex) {
    mu.Lock()
    defer mu.Unlock() // will also run at return
    // ...
    mu.Unlock() // first unlock โ€” looks harmless
    // function returns โ†’ defer fires โ†’ panic
}

// FIXED option A โ€” let defer do all the work
func fixedA(mu *sync.Mutex) {
    mu.Lock()
    defer mu.Unlock()
    // ...
    // no explicit Unlock anywhere below this line
}

// FIXED option B โ€” ditch defer, go fully explicit
func fixedB(mu *sync.Mutex) error {
    mu.Lock()
    // ...
    if err := validate(); err != nil {
        mu.Unlock()
        return err
    }
    mu.Unlock()
    return nil
}

Verify the fix

Three commands, run them in order:

# Basic sanity check
go run main.go

# Race detector pass
go run -race main.go

# Full test suite with race detection
go test -race -count=1 ./...

Write a targeted test that hammers the exact code path that was panicking. For example, if SafeCounter was the culprit:

func TestNoDoubleLock(t *testing.T) {
    var wg sync.WaitGroup
    sc := &SafeCounter{}

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            sc.Increment()
        }()
    }
    wg.Wait()

    if sc.Value() != 1000 {
        t.Fatalf("expected 1000, got %d", sc.Value())
    }
}

1000 goroutines, no sleeps, race detector on. If it passes clean, the fix holds.

Quick reference

  • Double Unlock โ†’ delete the extra call. One Lock, one Unlock, full stop.
  • defer + manual Unlock โ†’ commit to one style per function. Never both.
  • Unlock inside loop, Lock outside โ†’ move the Lock inside the loop body.
  • RWMutex variant โ†’ RLock/RUnlock and Lock/Unlock are separate pairs. Don't cross the streams.

Related Error Notes