skip to content

How do sync.OnceValue and sync.OnceValues differ from a sync.Once beside a package variable?

level: middleimportance: nice to knowfreq 30%

answer

  1. Go 1.21 added three helpers
  2. a function value instead of a package variable
  3. one memoizes a value and an error
  4. panic behaviour differs from Once.Do

basics

~20 s

sync.OnceFunc, sync.OnceValue and sync.OnceValues wrap once-only execution into a function value. Calling the returned function runs the wrapped function at most once and hands every caller the same memoized result, so no package-level variable is needed.

solid answer

~50 s

Added in Go 1.21, the three helpers turn the `Once`-plus-variable pattern into a single function value. `sync.OnceFunc(f func()) func()` memoizes a plain call; `sync.OnceValue[T](f func() T) func() T` memoizes one result; `sync.OnceValues[T1, T2](f func() (T1, T2)) (func() (T1, T2))` memoizes two, which is exactly the `(value, error)` shape a lazy initialiser wants. The win is that the mutable package variable disappears: instead of a `Once` and a `*Client` that anything in the package can reassign, you get one `var get = sync.OnceValue(...)` whose result nothing else can touch, and the accessor is the value itself. Panic behaviour also differs, and in a useful direction — if `f` panics, the returned function panics with the same value on **every** subsequent call, whereas `Once.Do` quietly returns without calling `f` again and leaves callers with a zero value.

code

go · 11 lines
go
var config = sync.OnceValues(func() (*Config, error) {
	return parseConfig(os.Getenv("APP_CONFIG"))
})

func handle() error {
	cfg, err := config()
	if err != nil {
		return err
	}
	return serve(cfg)
}

go deeper

for a junior

Know that these helpers exist and that they return a function you call to get the value. Recognising var get = sync.OnceValue(...) in a codebase is enough at this level.

for a middle

Be able to state the three signatures and say what each replaces: a Once plus a package variable, plus the error variable that the (value, error) form absorbs. Mention that they were added in Go 1.21.

for a senior

Draw out the panic contrast — OnceValue re-panics with the same value on every call while Once.Do silently stops calling f — and note that a cached error is cached for the life of the process, so transient failures must not go through these at all.

for a principal

Own the wider call: a tidier global is still a global. Decide where in a codebase memoized package-level construction is acceptable and where dependencies must be built by main and injected, and make that rule explicit for the team.

## The pattern the helpers replace Before Go 1.21 the only tool was `sync.Once`, and the idiom around it always looked the same: ```go var ( once sync.Once cfg *Config err error ) func config() (*Config, error) { once.Do(func() { cfg, err = parseConfig(os.Getenv("APP_CONFIG")) }) return cfg, err } ``` Three package-level variables and an accessor, for one lazily built object. Because `Do` takes a `func()` — no parameters, no results — the results have to escape the closure by assignment to variables that live outside it, and those variables are writable by anything else in the package. ## The three helpers Go 1.21 added: ```go func OnceFunc(f func()) func() func OnceValue[T any](f func() T) func() T func OnceValues[T1, T2 any](f func() (T1, T2)) func() (T1, T2) ``` Each takes a function and returns a wrapper. The wrapper may be called concurrently by any number of goroutines; the wrapped function runs at most once, later callers block until that run finishes, and everyone receives the memoized results. The example above collapses to: ```go var config = sync.OnceValues(func() (*Config, error) { return parseConfig(os.Getenv("APP_CONFIG")) }) ``` and callers write `cfg, err := config()`. ## What actually improves **The mutable globals are gone.** The memoized value lives inside the closure the helper created. There is no exported or even package-visible variable holding it, so no other function can reassign it, and no test can accidentally leave it half-set. **The result type is expressed in the signature.** `sync.OnceValues` returns a `func() (*Config, error)` — the accessor's contract is visible at the declaration rather than reconstructed from an accessor function further down the file. **`OnceValues` matches Go's error convention.** Lazy initialisation almost always can fail, and `Once.Do` has no channel for that: `Do` returns nothing, so the error must be stashed in a variable. `OnceValues` makes `(T, error)` the natural shape. **`OnceFunc` covers the no-result case**, such as a one-time registration or a one-time log line, where you only want the guard and not a value. ## The panic difference — the part worth knowing The two APIs behave differently when the wrapped function panics, and the difference is not cosmetic. - `Once.Do`: if `f` panics, `Do` considers `f` to have returned. The `Once` is done. Every later call to `Do` returns immediately **without** calling `f`, so callers silently receive whatever the partial initialisation left behind — usually a nil pointer, which then panics somewhere else entirely. - `OnceFunc`, `OnceValue`, `OnceValues`: if `f` panics, the returned function panics with the same value on every subsequent call. The failure keeps announcing itself at the point of use instead of degrading into a distant nil dereference. Neither is a substitute for simply not panicking in an initialiser — returning an error from an `OnceValues` function is better than either — but the helpers fail loudly where `Once.Do` fails silently. ## What does not change The helpers are still once-per-process with no retry. There is no reset, no expiry, and no way to re-run after a failure; if the wrapped function returns an error, every caller for the rest of the process gets that same cached error. If the underlying work can fail transiently — a dial that may succeed on the second attempt — none of these primitives is the right tool, and you want explicit state you can clear, or eager construction at startup. They also do not change ownership. A `var get = sync.OnceValue(...)` at package scope is still a global with a hidden lifetime; it is just a tidier one. When a service's dependencies should be built and wired by `main`, the helper is a convenience for library-internal memoisation, not a licence to keep the global. ## Generics, briefly `OnceValue` and `OnceValues` are generic functions with `any` type parameters, which is why they could not exist before generics landed in Go 1.18. Type inference means you rarely write the type arguments — `sync.OnceValue(func() *Config { ... })` infers `T = *Config` from the literal.

  • Why could sync.OnceValue not have been added before Go 1.18?
    It is a generic function: `OnceValue[T any](f func() T) func() T` needs a type parameter to memoize an arbitrary result type. Before type parameters landed in Go 1.18 the only options were `any` plus a type assertion at every call site, or a hand-written helper per type — neither of which is worth putting in the standard library.
  • If the function inside sync.OnceValues returns an error, what do later callers get?
    The same error, forever. The helper memoizes both results of the first and only run, so a failed initialisation is cached exactly like a successful one. There is no retry and no way to clear it. If the failure could be transient, do not memoize it — use explicit state you can reset, or build the dependency eagerly at startup where a failure can stop the process.
  • When is sync.OnceFunc preferable to sync.OnceValue?
    When the one-time work produces no value you need back — registering a handler, warming a cache in place, emitting a single log line. `OnceFunc` returns a `func()` that carries the same at-most-once and callers-block guarantees without inventing a result type just to satisfy the signature.

saying these in an interview costs you the question

  • Thinks the helpers can be reset or re-run after a failure
  • Says a cached error from OnceValues is retried on the next call
  • Believes OnceValue silently swallows a panic like Once.Do does
  • Claims the helpers remove the need for any synchronisation