- Book: Complete Guide to Go Programming
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Open any Go codebase past a year old and you will hit this pattern:
var (
cfgOnce sync.Once
cfg *Config
cfgErr error
)
func GetConfig() (*Config, error) {
cfgOnce.Do(func() {
cfg, cfgErr = loadConfig()
})
return cfg, cfgErr
}
Three package-level variables. A closure that mutates two of them by name. A function that reads them after Do returns. It works. It has worked since Go 1.0. It also leaks state into package scope, makes the call site harder to reason about, and gives you nothing to inject into a struct field if you want a per-instance memoized initializer.
Go 1.21 added three helpers that compress most of this boilerplate down to one line: sync.OnceFunc, sync.OnceValue, and sync.OnceValues. Two years in, the old shape is still common in the wild.
The four shapes, side by side
sync.Once is the original. Three new helpers all wrap it.
// 1. Classic sync.Once + closure.
var once sync.Once
var initErr error
func warmCaches() {
once.Do(func() {
initErr = primeAll()
})
}
// 2. sync.OnceFunc — wraps a no-return func into an idempotent one.
warmCaches := sync.OnceFunc(func() {
log.Println("warming caches")
primeAllNoReturn()
})
// 3. sync.OnceValue[T] — memoizes a single return value.
getRegion := sync.OnceValue(func() string {
return os.Getenv("AWS_REGION")
})
// 4. sync.OnceValues[T, U] — memoizes two return values
// (almost always value + error).
getConfig := sync.OnceValues(func() (*Config, error) {
return loadConfig()
})
The shape difference is small but real. Each of OnceFunc, OnceValue, OnceValues returns a function. You assign that function to a variable (package-level, struct field, or closure capture) and you call it like any other func. No Once field to declare next to the result. No package-level cfg/cfgErr pair. The memoization lives inside the returned closure.
OnceFunc: the boring one that still earns its place
OnceFunc is the simplest of the three. You give it a func(), you get back a func() that runs the inner one exactly once across all callers, and is safe to call from any goroutine. It is once.Do(...) you can pass around as a value.
type Worker struct {
closeOnce func()
closing chan struct{}
}
func NewWorker() *Worker {
w := &Worker{closing: make(chan struct{})}
w.closeOnce = sync.OnceFunc(func() {
close(w.closing)
})
return w
}
func (w *Worker) Close() {
w.closeOnce()
}
The classic version of this is the sync.Once-as-a-struct-field pattern, where you write w.once.Do(func() { close(w.closing) }) inside Close. Both compile. Both work. The OnceFunc version is shorter, the closure is set up once at construction time, and Close reads as one method call, not "do this inside a Once".
The realistic place this shows up: anywhere you have a Close, Cancel, Stop, or Done method that must be safe to call multiple times. Channels close exactly once. File handles close exactly once. A subscription cancellation runs exactly once. OnceFunc is the boring right answer for all of them.
One subtlety. If the wrapped function panics, the panic is cached. Subsequent calls re-panic with the same value, just like sync.Once.Do. Do not put a panic-prone operation inside a OnceFunc and expect retries to work. They will not.
OnceValue: the lazy memoized getter
OnceValue[T] is what you reach for when you have a single value that is expensive to compute, never changes after the first computation, and never returns an error.
var hostFP = sync.OnceValue(func() string {
h, err := os.Hostname()
if err != nil {
return "unknown"
}
return fingerprint(h)
})
func tagMetric(m *Metric) {
m.Host = hostFP() // cheap on every call after the first.
}
Compare to the classic shape:
var (
fpOnce sync.Once
fpVal string
)
func hostFP() string {
fpOnce.Do(func() {
h, err := os.Hostname()
if err != nil {
fpVal = "unknown"
return
}
fpVal = fingerprint(h)
})
return fpVal
}
The OnceValue version is one expression. The state lives inside the returned closure: there is no fpVal package variable for some other code path to mutate by accident, and no fpOnce field next to it that has to stay in sync.
OnceValue plays well with struct fields too. Useful when you want lazy per-instance memoization without a sync.Once + result pair on every struct:
type Resolver struct {
region func() string
}
func NewResolver(env *Env) *Resolver {
return &Resolver{
region: sync.OnceValue(func() string {
return env.Lookup("AWS_REGION")
}),
}
}
Each Resolver has its own one-shot region resolver. No shared state. No package globals.
OnceValues: the one most people get wrong
Here is the trap from the brief. You have a function that returns (T, error). You want to memoize it. You reach for OnceValue because it has "value" in the name. You write something like this:
// WRONG. Compiles, but discards the error.
getConfig := sync.OnceValue(func() *Config {
cfg, err := loadConfig()
if err != nil {
log.Fatal(err) // your only option
}
return cfg
})
OnceValue is a single-return helper. If your inner function returns (T, error), you have to either drop the error, panic, or log.Fatal. None of those are what you want for a config loader that might fail in a controlled way.
OnceValues[T, U] is the right tool. It memoizes two return values, and the second one is almost always error:
var loadCfg = sync.OnceValues(func() (*Config, error) {
return loadConfig()
})
func handler(w http.ResponseWriter, r *http.Request) {
cfg, err := loadCfg()
if err != nil {
http.Error(w, "config unavailable", http.StatusInternalServerError)
return
}
serve(w, r, cfg)
}
Two returns. Both are memoized. Every caller after the first gets the cached pair, including the cached error. That last point matters: if your initializer fails the first time, every subsequent call gets the same error. There is no retry. If you need retries on failure, OnceValues is the wrong primitive. Write a singleflight-backed loader instead.
Concurrency semantics: what the helpers guarantee
All four primitives (Once.Do, OnceFunc, OnceValue, OnceValues) guarantee:
- The wrapped function runs exactly once, even under contention.
- All callers see the same return value (or no return, for
OnceFunc/Once.Do). - The "first" caller is the one whose call actually runs the inner function. Every other caller blocks until that first call completes.
- A panic during the inner function is cached. Subsequent calls re-panic with the same value.
Point 3 is the one that surprises people. OnceValue is not non-blocking. If goroutine A calls getConfig() and the loader takes 800ms, goroutines B, C, D calling getConfig() during that window all block until A's call returns. They block, no cached zero, no parallel reload.
This is usually what you want — it is exactly the behavior that prevents the thundering-herd "ten goroutines all call loadConfig() at startup" disaster. But it does mean you should not call OnceValue-wrapped initializers from inside the hot path of a request handler if the initializer takes seconds. Warm them at startup.
Replacing the classic init pattern, end to end
The brief calls this out and it is worth showing. Here is the full migration, package-level config singleton edition.
Before:
package config
import "sync"
var (
cfgOnce sync.Once
cfg *Config
cfgErr error
)
func Get() (*Config, error) {
cfgOnce.Do(func() {
cfg, cfgErr = load()
})
return cfg, cfgErr
}
Three package-level vars. Two of them are mutated inside a closure. The third is the lock that guards them.
After:
package config
import "sync"
var Get = sync.OnceValues(load)
// load returns (*Config, error). Get is now a function value
// that memoizes the first call's result.
One line. Same external API — callers still write cfg, err := config.Get(). Internal state has shrunk from three named globals to one. The closure that holds (*Config, error) lives entirely inside Get's implementation, where no other package code can reach it.
Worth noting: Get is now a var, not a func. It is a function value. For 99% of callers this is invisible — config.Get() works either way. The 1% case: if some other package code took the address of config.Get (rare, but legal), that breaks. In practice, almost no Go code takes the address of a package function, and the migration is safe.
When the old pattern still wins
OnceValues is not always the right answer. Three cases where the classic sync.Once shape still earns its keep:
1. You need retries on failure. Once* helpers cache the failure forever. If your loader can transiently fail (network blip, slow DNS) and you want the next caller to retry, you need a different primitive — singleflight.Group is the usual choice, with a TTL or an error gate.
2. You need to expose the underlying Once for testing. Some test helpers reach into the package and call Reset by replacing the sync.Once value. With OnceValues-as-var, you have to replace the whole function, which is a slightly different shape. Both work; the Once version is sometimes more familiar.
3. The init function takes parameters. sync.OnceValues(func() (T, error)) requires a no-arg inner. If your initializer needs runtime input, you cannot wrap it directly — you have to capture the input in a closure at construction time. That is fine in many cases (struct-field initializers in particular), but for a package-level helper that takes a context or a key, you are back to a Once plus your own logic, or you reach for singleflight.
A small benchmarking note
If you are curious whether OnceValues is slower than the hand-rolled version, the obvious benchmark is easy to write. The post-init hot path (after the first call) is the same in both shapes: a single atomic load and a closure dispatch. The difference sits well below anything you would notice in a real handler.
If you measure something dramatically different on your hardware, look at closure capture before suspecting the sync primitive. OnceValue(func() T { ... }) allocates a small struct on the heap to hold the result and the once-flag. That allocation happens once, at the point where you call OnceValue. After that, every call is O(1) and lock-free on the fast path.
The pick-list
Decide by the return shape of the inner function:
- Inner returns nothing →
OnceFunc. ReplacesClose,Stop,Cancel,initside-effects. - Inner returns
T(no error) →OnceValue[T]. Replaces lazy-getter patterns for derived values. - Inner returns
(T, error)or any two values →OnceValues[T, U]. Replaces the classic config-loader shape. - Inner needs runtime args, or you need retries on failure → fall back to
sync.Once+ your own state, or usesingleflight.Group.
Two years in, the helpers are stable and well-trodden. Next time you write var once sync.Once followed by a result pair, try OnceValues first. It usually wins.
If this was useful
The Go standard library has a steady drip of these "small helper that replaces ten lines of boilerplate" additions — slices, maps, cmp, sync.Once*, iter, unique, testing/synctest. Tracking them and knowing when each one beats the older idiom is what separates Go code that ages well from code that drifts.
If you want the long-form treatment of how the language and stdlib actually fit together — concurrency primitives, memory model, the patterns that the type system rewards — that is the territory of The Complete Guide to Go Programming. Its companion volume, Hexagonal Architecture in Go, picks up where the language ends and shows what to do with packages, ports, and adapters once you are shipping real services.
Both ship in ebook, paperback, and hardcover.



Top comments (0)