Articles / Go

  • go
  • concurrency
  • sync-once
  • panic
  • error-handling
  • debugging

What Happens When the Function Inside sync.Once Panics?

If the function inside sync.Once panics, every later call quietly reports success and hands back whatever half-built state was left behind. Most people assume a failed setup gets another go next time. That assumption is wrong, and it fails silently rather than loudly.

The usual 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

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:

  • the panic propagates every time, so the failure is at least visible
  • the mutex stays locked and everyone deadlocks

Neither is right.

The source marks the Once as done even when f panics

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>

Calls 2 and 3 lie to you

Call 1 fails loudly and gets recovered somewhere sensible (an HTTP middleware's panic recovery, say). Everyone breathes a sigh of relief that the server didn't crash. Calls 2 and 3 report success, and 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, so 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 re-panic on every call instead

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 and stores it. Then it 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. The original stack trace is 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.

Quick detour: the one way Once really does hang

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.

Store the error, and let Once guard only the attempt

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. That's 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 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.