When sync.Once Is the Right Guard for Go Initialization
Use sync.Once for one-time, idempotent Go initialization and avoid it for retryable work. See how it serializes concurrent Do calls, a verifiable config pattern, and the panic and non-idempotent pitfalls.
05 Sept 2026, 05:42 UTC

Two goroutines call GetDB at the same time and you end up opening two connections, racing on a global config, or panicking because initialization ran twice. The usual fix is sync.Once — but only when the work it guards is genuinely one-time and idempotent. Point it at a retryable operation and the first failure becomes permanent for the life of the process.
The race a nil check does not close
Packages often need one shared resource: a config struct, a logger, a connection pool. The naive lazy check is not safe under concurrency:
if cfg == nil {
cfg = loadConfig()
}Two goroutines can both observe nil and both run loadConfig. sync.Once closes that window by holding an internal flag and a mutex. The first caller runs the function; later callers return immediately.
What Do actually guarantees
sync.Once exposes a single method, Do, which takes a function with no arguments and no return values. Its documented behavior:
- Concurrent
Docalls are safe without external locking — an internal mutex serializes them. - The first caller executes the function; subsequent calls are no-ops, including from other goroutines.
- If the function panics, the panic propagates to the caller and the
Onceis treated as done. LaterDocalls return without running the function again. Recovery is the caller's responsibility.
That third point is the one people get wrong. The Go documentation states that if the function panics, Do considers it to have returned; the wording was clarified in Go 1.21. Regardless of toolchain, do not plan on a retry after a panic — the initializer is spent. If that distinction matters to your design, confirm it on your own Go version with the test described below rather than assuming.
A worked example: lazy config
Package-level initialization for a config value:
package config
import "sync"
var (
once sync.Once
cfg *Config
)
type Config struct {
Addr string
}
func loadConfig() *Config {
return &Config{Addr: "localhost:8080"}
}
func Get() *Config {
once.Do(func() {
cfg = loadConfig()
})
return cfg
}Any number of goroutines can call Get concurrently. The first one runs loadConfig; the rest block on the internal mutex and then return the same pointer. Keep the function passed to Do small and free of panics, and make sure it is the only path that assigns cfg.
To check the guarantee, write a test that increments a counter inside the Do function, calls Do from many goroutines, and asserts the counter ends at exactly one. Run it from the package directory as a normal user — no elevated permissions are required:
go test -run TestOnce -count=1 ./...Expected check: the test passes and the counter is 1. If it is greater than 1, something else is also calling loadConfig directly; grep for the initializer name. The risk in this pattern is that a panic inside loadConfig leaves you with no working config and no automatic retry.
Where Once is the wrong tool
sync.Once fits stable, idempotent, one-time setup of package globals. It fits badly when:
- The operation can fail transiently — a network call, a health check, a token fetch. The first failure poisons the initializer for the lifetime of the process.
- You need to reload state, for example on
SIGHUP.Oncecannot be reset; you would need a freshOnceor a different mechanism. - The guarded work has side effects that are not idempotent, such as appending to a file or charging a payment. Silent skips after the first call can corrupt state.
For those cases, use explicit synchronization: a sync.Mutex with a retry loop, sync.RWMutex around a reloadable value, or golang.org/x/sync/singleflight when you want concurrent callers to share one in-flight attempt but allow a later retry.
Actionable closing
Reach for sync.Once when you need exactly-once, idempotent setup of a package-level value and skipping it later is harmless. Keep the guarded function short, panic-free, and the only writer of the variable it initializes. Prove the behavior with a concurrent test that asserts a single execution via a counter. If you need retry, cancellation, or reload, pick explicit locking instead — Once gives you exactly one shot, and that is the whole point.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.