Go's sync.Once Isn't a Mutex: What Actually Happens When f() Panics
Most people's mental model of sync.Once is "a mutex-guarded nil check that someone else already wrote correctly." You reach for it whenever you have expensive, one-time setup: a database connection, a parsed config file, a TLS certificate pool. The assumption baked into that mental model is that if the setup fails, you'll get another go at it next time. That assumption is wrong, and it's wrong in a way that fails silently rather than loudly.
Here's the pattern everyone writes at some point:
var (
once sync.Once
client *conn
)
func getClient() *conn {
once.Do(func() {
client = mustConnect()
})
return client
}
If mustConnect panics on the first call, what happens to the second caller? Most engineers guess one of two things: either the panic propagates every time (so the failure is at least visible), or the mutex stays locked and everyone deadlocks. Neither is right.
What the source actually does
The doc comment on sync.Once.Do spells it out plainly, if you stop to read it:
If f panics, Do considers it to have returned; future calls of Do return without calling f.
The implementation (this is the real source from the standard library) makes it obvious why:
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if !o.done.Load() {
defer o.done.Store(true)
f()
}
}
defer o.done.Store(true) is scheduled before f() ever runs. Deferred calls execute during a panic's stack unwinding, not just on a normal return, so if f() panics, done still flips to true on the way out. defer o.m.Unlock() fires too. By the time the panic reaches whoever calls getClient(), the Once has already marked itself permanently complete.
So there's no deadlock and no repeated panic. What you get is worse in a specific way: silence. Here's a minimal reproduction:
func getClient() (c *conn, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
once.Do(func() { panic("dial tcp: connection refused") })
return client, nil
}
func main() {
for i := 1; i <= 3; i++ {
c, err := getClient()
fmt.Printf("call %d: client=%v err=%v\n", i, c, err)
}
}
Running that gives:
call 1: client=<nil> err=recovered: dial tcp: connection refused
call 2: client=<nil> err=<nil>
call 3: client=<nil> err=<nil>
Call 1 fails loudly, gets recovered somewhere sensible (an HTTP middleware's panic recovery, say), and everyone breathes a sigh of relief that the server didn't crash. Calls 2 and 3 report success. client is still nil. Nothing downstream is told that initialisation never actually happened; it just finds a nil pointer where a connection should be, possibly minutes or hours later, possibly in a completely different goroutine, with a stack trace that has nothing to do with the original failure.
This is the actual gotcha in sync.Once: not that it blocks forever, but that it forgives forever. A hand-rolled version of the same pattern using a plain mutex would behave completely differently:
func getClient() *conn {
mu.Lock()
defer mu.Unlock()
if client == nil {
client = mustConnect() // panics leave client nil, mutex still unlocks
}
return client
}
With this version, a panic in mustConnect leaves client nil and the mutex unlocked (again thanks to defer), but there's no equivalent of done to short-circuit the next attempt. The very next caller will retry mustConnect. This is precisely the behaviour people assume sync.Once gives them, and precisely the behaviour it doesn't.
The 1.21 helpers do the opposite
Go 1.21 added sync.OnceFunc, sync.OnceValue and sync.OnceValues, which wrap a plain Once but handle panics deliberately, and differently. Their doc comments say: "If f panics, the returned function will panic with the same value on every call." Looking at the source for OnceValue, it recovers the panic from inside the wrapped Once.Do call, stores it, and re-panics it both on the call that triggered it and on every subsequent call:
return func() T {
d.once.Do(func() {
defer func() {
d.f = nil
d.p = recover()
if !d.valid {
panic(d.p)
}
}()
d.result = d.f()
d.valid = true
})
if !d.valid {
panic(d.p)
}
return d.result
}
Verified against a real run:
call 1 panicked: dial tcp: connection refused
call 2 panicked: dial tcp: connection refused
call 3 panicked: dial tcp: connection refused
So the two families genuinely disagree with each other. Plain sync.Once treats a panic as a completed, successful run and never speaks of it again. sync.OnceValue and friends treat it as a permanent, repeating failure that every future caller inherits, with the original stack trace preserved on the first occurrence and re-thrown (without a fresh trace into f) after that. Neither one retries. If your mental model was "it'll try again next time," both of these will surprise you, just in opposite directions.
There's a third, genuinely different footgun worth knowing about while we're here: the same doc comment that describes the panic behaviour also says "if f causes Do to be called, it will deadlock." That's a real, unrecoverable deadlock, because sync.Mutex isn't reentrant, and doSlow is still holding o.m when f() runs. If your initialisation function ever calls back into the same Once, directly or through some indirect path, that's the one case where you actually do get stuck forever.
What to do instead
If your once-off initialisation can fail and you want callers to be able to retry, don't rely on panic/recover semantics from either family. Store the error explicitly and let Once only guard the attempt, not the outcome:
var (
connectOnce sync.Once
client *conn
connectErr error
)
func getClient() (*conn, error) {
connectOnce.Do(func() {
client, connectErr = connect() // connect returns (*conn, error), never panics
})
return client, connectErr
}
This still only calls connect once, ever, which is honest about what sync.Once is actually for: running something exactly once, not running something until it succeeds. If you genuinely want retry-on-failure semantics, sync.Once is the wrong primitive regardless of how you handle panics; you want something that resets its own "done" state on failure, which is a few lines of a mutex and a boolean, not a stdlib type.
The general lesson is one that applies well beyond this specific type: reading the source of a two-field struct in the standard library takes about thirty seconds, and it's a lot cheaper than debugging a nil pointer dereference in production three weeks after the one panic that caused it.