sync.OnceValue turns a function into a concurrency-safe, one-time computation whose result is returned on every call. The first caller performs the computation. Concurrent callers wait for that call to finish, and later callers receive the stored result without running the function again.
This differs from using sync.Once with a separate result variable. The value and its one-time initialization are packaged behind a function, which makes the lifetime of the cached result explicit in the place where that function is stored.
The API was added in Go 1.21 alongside sync.OnceFunc and sync.OnceValues.
The returned function owns one computation
The signature accepts a function returning one value:
func OnceValue[T any](f func() T) func() TA package can use it for process-lifetime initialization:
var serviceName = sync.OnceValue(func() string {
value := os.Getenv("SERVICE_NAME")
if value == "" {
return "api"
}
return value
})Every call to serviceName() returns the value produced by the first execution. The wrapper may be called concurrently, so callers do not need an additional mutex around the initialization.
The cache belongs to the returned function. Creating another wrapper creates another independent one-time computation:
func newTokenSource() func() string {
return sync.OnceValue(func() string {
return createToken()
})
}Each call to newTokenSource produces a separate cache. That property makes scope more significant than the syntax itself: a package variable has package-lifetime behavior, while a wrapper stored on an object has object-lifetime behavior.
Two return values fit sync.OnceValues
Initialization often produces a value and an error. sync.OnceValues handles a function with two return values:
var loadConfig = sync.OnceValues(func() (*Config, error) {
data, err := os.ReadFile("config.json")
if err != nil {
return nil, err
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
})The first call runs the file read and decode. Every later call receives the same pair returned by that execution.
That includes errors. If the first call returns nil, err, later calls return that same cached result rather than attempting the operation again. OnceValues therefore represents one-time initialization, not retry logic.
This distinction matters for dependencies that can recover after a temporary failure. A DNS lookup, remote request, expiring credential, or service discovery operation usually has a lifetime that does not match an irreversible one-shot cache. A transient failure becoming permanent for the wrapper can be a larger semantic change than the removal of repeated work.
Concurrent callers wait for the first call
The wrapper does not publish a partially computed result. If several goroutines call it before initialization has completed, one invocation of the supplied function runs and the other calls wait for that invocation to return.
That gives the result a clear publication boundary. State constructed inside the initializer can be returned without a second synchronization mechanism merely to make the initialized value visible to callers.
It does not make the returned object immutable or concurrency-safe. If the cached value points to mutable state, all callers receive access to that same state:
var shared = sync.OnceValue(func() *bytes.Buffer {
return new(bytes.Buffer)
})The one-time creation of the buffer is safe. Concurrent writes through shared() are a separate synchronization problem because bytes.Buffer does not gain concurrent-use guarantees from OnceValue.
The same boundary applies to maps, slices, clients with mutable configuration, and pointers to application state. OnceValue coordinates construction; it does not coordinate later mutation.
Panics are replayed
A panic during initialization is not treated like an ordinary failed call that can be attempted again. If the supplied function panics, every call to the returned wrapper panics with the same panic value.
That behavior differs from sync.Once.Do. A panic from the function passed to Once.Do marks the Once as completed, but later calls to Do return without repeating that panic. The function wrappers added in Go 1.21 intentionally replay the panic on subsequent calls.
For initialization code, this gives all callers a consistent outcome: there is no first caller that observes a panic followed by later callers receiving a zero value as though initialization had succeeded.
A recover boundary can still intercept a panic in the usual Go manner, but recovery does not reset the wrapper. A later call encounters the cached panic behavior again.
Arguments require an explicit cache boundary
OnceValue accepts a zero-argument function. Values needed by the initializer are normally captured by a closure:
func configLoader(path string) func() (*Config, error) {
return sync.OnceValues(func() (*Config, error) {
return readConfig(path)
})
}Here path is fixed when configLoader creates the wrapper. Calling the returned function does not select among paths.
This is materially different from memoization. A memoized function commonly caches separate results for separate argument sets. OnceValue has one result slot for the wrapper, so using it behind an API that accepts changing inputs can silently bind all calls to whichever input was captured during wrapper creation.
When the intended cache key is an argument, a keyed cache or another data structure is the more accurate model.
Refreshable data needs another mechanism
A one-time result has no invalidation method. The standard API does not expose a reset operation for OnceValue or OnceValues.
That makes the functions suitable when the result is intended to remain stable for the wrapper’s entire lifetime: parsed static metadata, an initialized lookup table, a process-wide immutable setting, or another value with the same lifetime as its holder.
Data with rotation, expiry, file-change detection, administrative reloads, or periodic refresh has a different state model. Replacing the entire holder can create a new one-time wrapper, but using replacement as an implicit reset mechanism should be an intentional ownership decision rather than an accidental side effect.
sync.OnceValue is most precise when the program can state a simple invariant: this wrapper has one initialization attempt and one resulting outcome. Once that invariant stops matching the data’s lifetime, a refreshable cache or explicit state machine expresses the behavior more accurately.