sync.OnceFunc — Junior¶
The problem these helpers solve¶
Sometimes you want a piece of code to run exactly once, no matter how many goroutines try to trigger it and no matter how many times each goroutine calls in. Three classic examples:
- Opening a log file the first time anything wants to log.
- Parsing a config file lazily, on the first call to any function that needs the config.
- Closing a network connection — every call to
Close()after the first should be a no-op.
All three are variants of the same problem: "run this initialization exactly once, then keep handing out the result".
Before Go 1.21 you wrote this with sync.Once. After Go 1.21 you almost never need to type sync.Once again — there are three small helpers (sync.OnceFunc, sync.OnceValue, sync.OnceValues) that capture the pattern more directly. This page is about understanding the old pattern, why it was awkward, and how the new helpers replace it.
The old way: sync.Once¶
Here is the canonical pre-1.21 lazy logger:
package logger
import (
"log"
"os"
"sync"
)
var (
initOnce sync.Once
logger *log.Logger
)
func Get() *log.Logger {
initOnce.Do(func() {
f, err := os.OpenFile("/tmp/app.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
panic(err)
}
logger = log.New(f, "", log.LstdFlags)
})
return logger
}
Let me count the moving parts:
- A
sync.Oncevalue at package scope, just to track whether init has happened. - A
*log.Loggervalue at package scope, to hold the result. - A
Getfunction that hides both. - A closure passed to
initOnce.Dothat does the actual work.
Four pieces of state for one idea ("a logger that is created on first use"). The closure inside Do cannot return anything — Do ignores return values — so the only way to communicate the result back is to assign it to the package-level logger variable.
This works. It is correct. It has been the standard Go pattern for a decade. But it is verbose, and there are two specific footguns worth pointing out before showing the replacement.
Footgun 1 — sync.Once.Do does not return anything¶
The signature of Once.Do is func (o *Once) Do(f func()). The wrapped function has signature func(). No arguments, no return. If you want to compute a value, you must use a side effect:
var (
loadOnce sync.Once
config *Config
loadErr error
)
func Load() (*Config, error) {
loadOnce.Do(func() {
config, loadErr = parseConfig() // assign to package vars
})
return config, loadErr
}
You end up with three package-level variables — the Once, the result, and the error — instead of one accessor function. Every developer reading the file has to track all four pieces.
Footgun 2 — sync.Once.Do on panic¶
If f panics inside Do, the Once is consumed — internally it has already been marked as "done" before f runs. So the panic propagates to the first caller, but every subsequent caller of Do sees the Once as done and returns immediately, as if everything had worked.
var once sync.Once
var x int
func init1() {
once.Do(func() { panic("boom") })
}
func init2() {
once.Do(func() { x = 42 }) // never runs
}
If goroutine A calls init1 and panics, then goroutine B calls init2, goroutine B's function is silently skipped — x stays zero. This is rarely what you want for a real initializer.
The new way: sync.OnceFunc, OnceValue, OnceValues¶
Go 1.21 added three functions to the sync package:
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 one takes a function and returns a wrapped function. The wrapper:
- Runs the wrapped function at most once, no matter how many calls or goroutines.
- Caches and returns the wrapped function's return value(s), for
OnceValueandOnceValues. - Re-panics with the same value on every subsequent call, if the wrapped function panicked.
- Drops its reference to the wrapped function after a successful first call, so any state it captured can be garbage-collected.
Rewriting the lazy logger:
package logger
import (
"log"
"os"
"sync"
)
var Get = sync.OnceValue(func() *log.Logger {
f, err := os.OpenFile("/tmp/app.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
panic(err)
}
return log.New(f, "", log.LstdFlags)
})
That's it. Get is now a function value of type func() *log.Logger. Callers use logger.Get() exactly as before. There is no sync.Once, no package-level logger variable, no separate Get function. One variable, one closure, done.
Three flavors, three shapes¶
Each helper is for a different return-value shape:
| Helper | Wrapped signature | Wrapper signature | Use when |
|---|---|---|---|
sync.OnceFunc | func() | func() | The work has side effects but produces nothing to cache (close a file, send a metric, fire a one-shot signal). |
sync.OnceValue | func() T | func() T | The work produces a single value and you want every caller to receive that same value (lazy config, compiled regex, opened resource). |
sync.OnceValues | func() (T1, T2) | func() (T1, T2) | The work produces a (value, error) pair, the most common Go idiom for fallible initialization. |
There is no OnceValues3 for three return values. The proposal authors decided that three-or-more return signatures are rare enough that callers can pack them into a struct and use OnceValue[Struct].
A first runnable example¶
Let's run something concrete. Save this as main.go:
package main
import (
"fmt"
"sync"
)
func main() {
greet := sync.OnceFunc(func() {
fmt.Println("hello, world")
})
for i := 0; i < 5; i++ {
greet()
}
}
Running it (go run main.go):
The closure printed once. Calls 2 through 5 just returned without doing anything.
Now let's do the same with OnceValue:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
expensive := sync.OnceValue(func() int {
fmt.Println("computing...")
time.Sleep(500 * time.Millisecond)
return 42
})
for i := 0; i < 3; i++ {
fmt.Println("got", expensive())
}
}
Output:
The first call to expensive() prints computing... and sleeps for 500 ms before returning 42. The next two calls return immediately with the cached 42.
And OnceValues:
package main
import (
"errors"
"fmt"
"sync"
)
func main() {
open := sync.OnceValues(func() (string, error) {
fmt.Println("opening connection")
return "conn://abc", nil
})
for i := 0; i < 3; i++ {
conn, err := open()
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("conn:", conn)
}
_ = errors.New
}
Output:
Same shape — one print of "opening connection", three returns of ("conn://abc", nil).
Comparison side by side¶
Old:
var (
once sync.Once
cfg *Config
cfgErr error
)
func Load() (*Config, error) {
once.Do(func() {
cfg, cfgErr = parseConfig()
})
return cfg, cfgErr
}
New:
(Where parseConfig has signature func() (*Config, error).) Three package variables and an accessor become a single variable that is the accessor.
Why "drop the captured function" matters¶
When you wrap a closure with sync.OnceValue, the closure can reference local variables, parameters, large data structures. As long as the wrapper is alive, anything the closure references is kept alive too. That would be bad — you'd be holding onto build-time scratch data forever.
The implementation deals with this by setting its internal pointer to the closure to nil after a successful first call. The closure becomes unreachable; everything it captured (other than the return value) becomes garbage-collectable.
Demonstration with a finalizer:
package main
import (
"fmt"
"runtime"
"sync"
)
type Big struct {
buf [1 << 20]byte // 1 MiB
}
func main() {
big := &Big{}
runtime.SetFinalizer(big, func(*Big) {
fmt.Println("big collected")
})
load := sync.OnceValue(func() int {
return len(big.buf) // captures big
})
fmt.Println("len:", load())
big = nil
runtime.GC()
runtime.GC()
fmt.Println("done")
}
Output (on a Go 1.21+ build):
After load() returned, the closure was discarded inside the wrapper, the only other reference (big in main) was set to nil, and the GC was free to collect the 1 MiB struct.
If you had used a hand-rolled sync.Once that kept the closure forever, the finalizer would never run and the 1 MiB would leak for the process lifetime. This is a real benefit, not a microbenchmark trick — OnceValue makes lazy loaders cleaner and less leaky.
The panic story¶
Take this code:
package main
import (
"fmt"
"sync"
)
func main() {
bad := sync.OnceFunc(func() {
panic("boom")
})
for i := 0; i < 3; i++ {
func() {
defer func() {
if r := recover(); r != nil {
fmt.Println("call", i, "recovered:", r)
}
}()
bad()
}()
}
}
Output:
Every call to bad() panics with the value "boom". The first panic carries a stack trace into the user's function; later panics carry a stack trace into the wrapper. But the value is identical.
Compare to sync.Once:
package main
import (
"fmt"
"sync"
)
func main() {
var once sync.Once
bad := func() {
once.Do(func() {
panic("boom")
})
}
for i := 0; i < 3; i++ {
func() {
defer func() {
if r := recover(); r != nil {
fmt.Println("call", i, "recovered:", r)
}
}()
bad()
}()
}
}
Output:
Only the first call panicked. Calls 1 and 2 hit once.Do, saw that the Once was already marked done, and returned silently. This is the footgun mentioned earlier: a panicking initializer fails for the first caller and silently succeeds for everybody else.
For real code, the OnceFunc behavior is almost always what you want. If your config file is corrupted, you want every caller that asks for the config to know that — not for them to receive a nil pointer because the first caller already crashed.
Concurrent safety¶
You can call the wrapper from any number of goroutines simultaneously. The very first one to reach the wrapper triggers the wrapped function; all others block until that first one returns; then everybody returns at once with the cached value.
package main
import (
"fmt"
"sync"
"time"
)
func main() {
load := sync.OnceValue(func() int {
fmt.Println("loading...")
time.Sleep(200 * time.Millisecond)
return 7
})
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
v := load()
fmt.Println("goroutine", i, "got", v)
}(i)
}
wg.Wait()
}
Output (order of goroutine prints will vary):
loading...
goroutine 0 got 7
goroutine 4 got 7
goroutine 1 got 7
goroutine 2 got 7
goroutine 3 got 7
There is exactly one loading... line. The five goroutines either ran the loader (one of them) or blocked on the internal sync.Once (the other four). After the 200 ms wait, all five received the same value.
When to use each helper¶
A quick decision tree:
- "I just want to run code once, no result." →
OnceFunc. - "I want one value." →
OnceValue. - "I want a value and an error." →
OnceValues. - "I want more than two values." → Pack into a struct, use
OnceValue[Struct]. - "I want to retry on failure." → None of these. Use a
sync.Mutex+ manual flag, or a different pattern entirely. - "I want to reset and run again later." → None of these. Same.
Common patterns¶
Idempotent Close¶
A struct that holds a resource, with Close() safe to call multiple times:
type Conn struct {
raw io.Closer
close func() error
}
func NewConn(raw io.Closer) *Conn {
c := &Conn{raw: raw}
c.close = sync.OnceValue(func() error {
return c.raw.Close()
})
return c
}
func (c *Conn) Close() error { return c.close() }
Conn.Close() runs raw.Close() exactly once. Subsequent calls return the same error (which is nil for a successful close). No sync.Once field on the struct, no separate closeErr field.
Lazy config¶
A package whose config is computed on first access:
package settings
import (
"encoding/json"
"os"
"sync"
)
type Settings struct {
Port int `json:"port"`
Host string `json:"host"`
}
var Load = sync.OnceValues(func() (*Settings, error) {
data, err := os.ReadFile("/etc/app/settings.json")
if err != nil {
return nil, err
}
var s Settings
if err := json.Unmarshal(data, &s); err != nil {
return nil, err
}
return &s, nil
})
Callers say settings.Load() and get (*Settings, error). The file is read at most once.
Compiled-regex cache¶
var emailRx = sync.OnceValue(func() *regexp.Regexp {
return regexp.MustCompile(`^[\w.+-]+@[\w-]+\.[\w.-]+$`)
})
func ValidEmail(s string) bool {
return emailRx().MatchString(s)
}
This is slightly more useful than var emailRx = regexp.MustCompile(...) because it defers compilation until the first call to ValidEmail. For a simple regex the saving is microseconds. For a complex one with hundreds of alternations, it can be milliseconds — paid only if the feature is used.
One-shot shutdown¶
var shutdown = sync.OnceFunc(func() {
fmt.Println("flushing logs")
fmt.Println("closing DB pool")
fmt.Println("done")
})
func main() {
defer shutdown()
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-sigs
shutdown()
os.Exit(0)
}()
serve()
}
If the process receives SIGTERM, the signal handler calls shutdown and os.Exit(0). If the process returns from main normally, the deferred shutdown runs. Either path, shutdown runs exactly once.
Things you should not do¶
Don't recreate the wrapper inside a function that runs more than once¶
Every HTTP request creates a brand-new OnceFunc. Each new wrapper runs setup exactly once — but there is a new wrapper per request, so setup actually runs on every request. The OnceFunc is not magic; it doesn't deduplicate across calls of the surrounding function. The wrapper must be created exactly once, typically at package scope or as a struct field initialized in a constructor.
Don't try to retry on error¶
If parseConfig fails, you want a retry, right?
var Load = sync.OnceValues(func() (*Config, error) {
return parseConfig()
})
func main() {
for i := 0; i < 3; i++ {
cfg, err := Load()
if err != nil {
time.Sleep(time.Second)
continue
}
use(cfg)
break
}
}
This does not retry. The first call ran parseConfig, got (nil, err), cached that pair, and returned. Calls 2 and 3 return the same (nil, err) without calling parseConfig again. If you need retry, use a sync.Mutex and a retry loop, or — even simpler — handle retries inside the wrapped function.
Don't reset by reassigning¶
This is a race: goroutines reading Load while Reload writes to it form a data race on the function-value slot, and even with synchronization, callers that copied Load into a local variable still see the old cached value forever. Don't use these helpers for anything that needs reloading.
Reading the source¶
The implementation in src/sync/oncefunc.go is short and worth reading once. The full OnceFunc:
func OnceFunc(f func()) func() {
var (
once Once
valid bool
p any
)
g := func() {
defer func() {
p = recover()
if !valid {
panic(p)
}
}()
f()
f = nil
valid = true
}
return func() {
once.Do(g)
if !valid {
panic(p)
}
}
}
The key lines:
f()runs the user's function.f = nildrops the reference afterfreturns successfully.valid = truerecords success.- The deferred recover captures any panic value into
p. - The outer wrapper rechecks
validafteronce.Do— if init failed, every call re-panics withp.
OnceValue and OnceValues differ only in that they capture and return one or two values respectively. The bookkeeping is identical.
Summary¶
sync.Onceis still in the language and still works. You just rarely have a reason to type it.- For a side-effect-only one-shot operation, use
sync.OnceFunc. - For a single cached value, use
sync.OnceValue. - For a
(value, error)pair, usesync.OnceValues. - Define the wrapper once (package variable or struct field), not per call.
- Don't expect retry on failure — panic-on-second-call (or cached-error-forever) is the contract.
- The wrapper is safe to call from any number of goroutines.
- After a successful first call, the wrapped closure is dropped so its captures can be GC'd.
If you find yourself writing var foo sync.Once in new Go 1.21+ code, stop and ask whether one of these three helpers does the same job in one variable. Almost always, the answer is yes.
Detail: what "exactly once" really means¶
There's a subtle distinction worth absorbing. "Exactly once" in the context of sync.OnceFunc means "the wrapped function f is invoked exactly once across all callers of the wrapper, ever". It does not mean "the wrapper itself runs exactly once" — the wrapper can run a billion times, it just only invokes f on the very first call.
Here's a small experiment to make that concrete:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var inner int64
var outer int64
wrapped := sync.OnceFunc(func() {
atomic.AddInt64(&inner, 1)
})
for i := 0; i < 1000; i++ {
wrapped()
atomic.AddInt64(&outer, 1)
}
fmt.Println("inner:", inner)
fmt.Println("outer:", outer)
}
Output:
The wrapped closure incremented inner exactly once. The outer loop incremented outer 1000 times, calling the wrapper each iteration. The wrapper itself ran 1000 times; the wrapped closure ran once.
This is the same shape as the underlying sync.Once: once.Do(f) can be called any number of times; f is invoked exactly once.
Detail: what counts as "the first call"?¶
If two goroutines call the wrapper simultaneously, exactly one of them — the runtime picks — will be the goroutine that executes f. The other one blocks on the internal sync.Once. When the first goroutine returns from f, both goroutines complete their wrapper call.
This is important for understanding panic semantics. The first call — the one that actually executes f — is the one whose stack trace is preserved when f panics. The other concurrent caller, even if it entered the wrapper at almost the same time, gets the "re-panic with cached value" path.
You should not write code that depends on which goroutine wins the race. It is undefined which one starts f. All you are promised is that f runs once and that all callers observe the result.
Detail: zero values vs uninitialized¶
A subtle point about OnceValue. The wrapper closure has a captured slot of type T to store the result. Until the first call, that slot holds the zero value of T. After the first call, it holds the value returned by f.
This matters because, from the outside, you cannot tell whether a sync.OnceValue wrapper has been called yet. There's no IsInitialized() method, no Reset(), no way to inspect state. The wrapper is opaque: a func() T that you call, and that's it.
If you need to know whether the underlying init has happened, you're using the wrong primitive. Use a sync.Mutex + an explicit bool.
Detail: the wrapper closure captures T by value¶
When OnceValue stores the result, it stores the actual value, not a pointer to it. So:
Every call to get() returns a fresh copy of the 4 KiB struct. The wrapper holds one canonical copy internally; each call returns a value-copy out. For large structs this is wasteful — you almost always want *Big:
Now every call returns the same 8-byte pointer. Callers share the underlying struct (which is fine for read-only data — config, regex, lookup tables).
If you accidentally let two callers mutate the same returned *Big, you have a data race. The OnceValue layer does nothing to protect the contents of *Big — only the initialization is synchronized. Treat the returned pointer as immutable, or wrap the struct in your own synchronization.
Detail: closures vs named functions¶
Both work:
// Named function:
func loadConfig() *Config { /* ... */ }
var Load = sync.OnceValue(loadConfig)
// Closure:
var Load = sync.OnceValue(func() *Config {
return &Config{Port: 8080}
})
When you pass a closure, it can capture local variables from the surrounding scope. When you pass a named function, it does not capture anything from sync.OnceValue's caller — it has access only to package-level state and its arguments (which is zero, since the wrapped function takes no arguments).
A common mistake is to wrap a method instead of a function:
type Service struct {
cfg *Config
}
func (s *Service) loadConfig() *Config { /* ... */ }
func NewService() *Service {
s := &Service{}
s.cfg = sync.OnceValue(s.loadConfig)() // compiles, but...
return s
}
The line sync.OnceValue(s.loadConfig) works because Go method values are functions that have the receiver bound. But notice the () at the end — it immediately calls the wrapper. That doesn't "lazy-init"; it eagerly inits at construction. To get lazy init, store the wrapper:
type Service struct {
loadCfg func() *Config
}
func NewService() *Service {
s := &Service{}
s.loadCfg = sync.OnceValue(s.loadConfigImpl)
return s
}
func (s *Service) loadConfigImpl() *Config { /* ... */ }
func (s *Service) Config() *Config { return s.loadCfg() }
Now Service.Config() is the public accessor and loadConfigImpl runs on the first call to Config().
A look at common signatures you'll convert¶
When porting old code, you'll meet these shapes most often:
Shape A: Once.Do with no result¶
Before:
var initOnce sync.Once
func ensureInit() {
initOnce.Do(func() {
registerMetrics()
startReporter()
})
}
After:
Shape B: Once.Do storing one value¶
Before:
var (
onceLogger sync.Once
logger *Logger
)
func L() *Logger {
onceLogger.Do(func() {
logger = newLogger()
})
return logger
}
After:
Shape C: Once.Do storing a value and an error¶
Before:
var (
onceDB sync.Once
db *sql.DB
dbErr error
)
func DB() (*sql.DB, error) {
onceDB.Do(func() {
db, dbErr = sql.Open("postgres", "...")
})
return db, dbErr
}
After:
Shape D: Once.Do inside a method, per-instance¶
Before:
type Service struct {
initOnce sync.Once
pool *Pool
}
func (s *Service) Pool() *Pool {
s.initOnce.Do(func() {
s.pool = newPool()
})
return s.pool
}
After:
type Service struct {
pool func() *Pool
}
func NewService() *Service {
s := &Service{}
s.pool = sync.OnceValue(newPool)
return s
}
func (s *Service) Pool() *Pool { return s.pool() }
Shape E: idempotent close¶
Before:
type Conn struct {
closeOnce sync.Once
closeErr error
raw net.Conn
}
func (c *Conn) Close() error {
c.closeOnce.Do(func() {
c.closeErr = c.raw.Close()
})
return c.closeErr
}
After:
type Conn struct {
close func() error
raw net.Conn
}
func NewConn(raw net.Conn) *Conn {
c := &Conn{raw: raw}
c.close = sync.OnceValue(func() error {
return c.raw.Close()
})
return c
}
func (c *Conn) Close() error { return c.close() }
In every "after" version, the struct or package has one fewer field, and the accessor function shrinks to a one-liner.
Detail: what about init() functions?¶
You might wonder, "if I want to do something lazily at package load, why not just use Go's init() function?" Two reasons:
init()runs eagerly, every time the program starts, whether or not the package's lazy resource is actually used.sync.OnceValueis lazy — it runs on first use.init()runs beforemain, but you cannot give it parameters and you cannot make it return values. Anything it sets up must live in a package variable.
If your initialization is cheap and always needed (compiling a small regex, building a lookup map of 10 entries), var rx = regexp.MustCompile(...) at package scope is the simplest answer — Go runs that during package init.
If your initialization is expensive, conditional, or might panic, sync.OnceValue defers it to actual usage and lets you handle the panic at the call site.
Detail: comparison with sync.Once and atomic.Bool¶
You might be tempted to "do it yourself" with atomic.Bool:
var (
inited atomic.Bool
value int
)
func Get() int {
if !inited.Load() {
if inited.CompareAndSwap(false, true) {
value = compute()
}
// else: someone else is computing; ???
}
return value
}
This is broken. If goroutine A wins the CAS and starts computing, goroutine B reads inited == true but value is still zero — A hasn't finished writing it yet. There's no happens-before relationship.
Neither sync.Once nor sync.OnceFunc have this problem — they internally use a mutex (or a more careful sequence of atomics) to ensure callers observe the completed value. Don't try to recreate them with raw atomics.
Detail: the wrapper as a value¶
The wrapper is a regular func() (or func() T, or func() (T1, T2)) value. You can:
- Pass it as an argument.
- Store it in a struct field.
- Store it in a map.
- Compare it for nil-ness (
if load == nil).
You cannot:
- Compare two wrappers for equality. (Go does not let you compare function values for equality, period.)
- "Reset" the wrapper. Once it's been called and returned, the cache is locked.
A useful idiom is to make the wrapper a factory parameter:
type Worker struct {
loadConfig func() *Config
}
func NewWorker(loadConfig func() *Config) *Worker {
return &Worker{loadConfig: loadConfig}
}
// In production:
w := NewWorker(sync.OnceValue(realLoadConfig))
// In tests:
w := NewWorker(func() *Config { return &Config{Port: 9999} })
The production worker gets cached lazy loading. The test worker gets a plain function that returns a fixed config. The Worker doesn't know or care which it has — it just calls loadConfig().
Detail: works fine across packages¶
The wrapper is just a function value; it can be exported, imported, passed around freely. A common style:
// package settings
var Load = sync.OnceValues(loadFromDisk)
// package main
import "myapp/settings"
func main() {
cfg, err := settings.Load()
// ...
}
settings.Load is the lazy loader. Every caller, in every package, calls settings.Load(). Together they invoke loadFromDisk exactly once.
Detail: testing code that uses these helpers¶
A package-level var Load = sync.OnceValue(...) is hard to test because the cache survives across tests in the same test binary. Two common approaches:
Approach 1 — Inject the wrapper¶
Don't make Load a package var. Make it a parameter to whatever uses it:
type Server struct {
load func() *Config
}
func New(load func() *Config) *Server { return &Server{load: load} }
In production:
In tests:
Each test creates its own *Server, with its own (or no) caching.
Approach 2 — Don't test the cache layer at all¶
The cache layer is two lines and trivially correct. Test the wrapped function directly:
You don't need to test that sync.OnceValue returns the cached value — that's the stdlib's job.
Detail: composition with other primitives¶
You can combine sync.OnceValue with other concurrency tools:
// Lazy WaitGroup-based parallel init:
var loadAll = sync.OnceValue(func() (*Config, *Tables, error) {
var wg sync.WaitGroup
var cfg *Config
var tbl *Tables
var errC, errT error
wg.Add(2)
go func() { defer wg.Done(); cfg, errC = loadConfig() }()
go func() { defer wg.Done(); tbl, errT = loadTables() }()
wg.Wait()
if errC != nil {
return nil, nil, errC
}
if errT != nil {
return nil, nil, errT
}
return cfg, tbl, nil
})
Note that this returns three values — too many for OnceValues. The workaround is to repack into a struct or use OnceValue[*struct{...}].
Detail: how it relates to the Go memory model¶
The Go memory model has an explicit clause for sync.Once.Do: the completion of f is synchronized before the return of every subsequent call. Since sync.OnceFunc is implemented in terms of sync.Once.Do, the same guarantee transfers: every call to the wrapper observes the effects of f as if f had completed before the call.
In practical terms: if f writes to some shared memory and the wrapper is called afterward, the read of that memory is safe — no separate atomic or mutex is required.
var (
setup = sync.OnceFunc(func() {
globalConfig = parseConfig() // assignment to package var
})
)
func handler(...) {
setup()
use(globalConfig) // safe — happens-after the write inside setup
}
You can rely on this without thinking about it, but it's worth knowing it exists when reading code that does similar tricks.
A worked example: lazy DB pool with everything¶
Let's pull every concept together into one realistic example. A package that:
- Lazily opens a database connection on first use.
- Returns the same pool to every caller.
- Reports an error if opening fails.
- Has an idempotent
Close()that waits for the lazy init to finish before closing.
package store
import (
"database/sql"
"fmt"
"sync"
_ "github.com/lib/pq"
)
type Store struct {
dsn string
open func() (*sql.DB, error)
close func() error
db *sql.DB
}
func New(dsn string) *Store {
s := &Store{dsn: dsn}
s.open = sync.OnceValues(func() (*sql.DB, error) {
db, err := sql.Open("postgres", s.dsn)
if err != nil {
return nil, fmt.Errorf("open: %w", err)
}
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("ping: %w", err)
}
s.db = db
return db, nil
})
s.close = sync.OnceValue(func() error {
// Force the open call to finish before we close.
_, _ = s.open()
if s.db == nil {
return nil // open never succeeded; nothing to close
}
return s.db.Close()
})
return s
}
func (s *Store) DB() (*sql.DB, error) { return s.open() }
func (s *Store) Close() error { return s.close() }
Notable details:
s.openis captured as a closure that usess.dsn. The wrapper holds ontos.open's body only until it succeeds; after that, the body is dropped (the wrapper closure insideOnceValuessets itsfto nil). The captureds.dsnstring would still live, indirectly, but it's tiny.s.closetriggerss.openbefore closing. IfClose()is called before anyDB(), this forces the lazy open and then immediately closes — which is the safe behavior if you wantCloseto release whatever was opened.- Both
DB()andClose()are safe under concurrent calls.
The same logic with raw sync.Once would be twice as long and would need to keep a separate dbErr field and an openOnce/closeOnce pair plus a closeErr field. Five extra fields for the same behavior.
Another worked example: lazy global rate limiter¶
package ratelimit
import (
"context"
"sync"
"time"
"golang.org/x/time/rate"
)
var Limiter = sync.OnceValue(func() *rate.Limiter {
return rate.NewLimiter(rate.Every(100*time.Millisecond), 10)
})
func Allow(ctx context.Context) error {
return Limiter().Wait(ctx)
}
A package-level lazy limiter. The limiter is constructed on the first call to Allow. Every subsequent call uses the same limiter. There is no init() function (so the limiter is not built if the package is imported but never used), no sync.Once variable, no separate var lim *rate.Limiter.
Frequently asked beginner questions¶
Q: Can I write sync.OnceFunc(myFunc) and just throw away the return value?
No — well, you can, but it does nothing useful. sync.OnceFunc doesn't run the function; it returns a wrapper. If you throw the wrapper away, you have no way to call it. You always have to store the wrapper somewhere.
Q: Does the wrapped function run when I call sync.OnceValue(f) (the constructor), or only on first call to the wrapper?
Only on the first call to the wrapper. Construction is cheap — it just sets up the internal state. The actual work of f is deferred to whenever the wrapper is first invoked.
Q: Can I use sync.OnceFunc with a function that takes arguments?
Not directly — OnceFunc accepts func(). To pass arguments, capture them in a closure:
load := sync.OnceValue(func() *Config {
return loadFromPath("/etc/app/config.json") // path captured by closure
})
The arguments are fixed at the time you build the wrapper. There's no way to pass different arguments to different calls — they'd all collapse to the same single execution anyway.
Q: What if I want to pass arguments to the wrapper itself?
You can't, by design. "Run exactly once with these arguments" is ambiguous when multiple callers pass different arguments. If you need that, you want singleflight.Group.Do, not sync.OnceFunc.
Q: Is there a way to clear the cache and force re-init?
No. The wrapper has no reset. If you need that, use a sync.Mutex + a bool flag yourself.
Q: Can I nest sync.OnceValue calls?
You can, but it's almost always wrong. If f inside OnceValue itself calls another OnceValue-wrapped function, that's fine — they're independent caches. But if you wrap a wrapper (sync.OnceValue(sync.OnceValue(f))), the outer one wraps the construction of the inner one, which doesn't do what you'd expect. Just use one wrapper.
Q: Performance impact?
A wrapper allocates a small closure (~64 bytes) once at construction. Each subsequent call does one atomic load + one indirect call + one branch — under 2 ns on modern hardware. Compared to the work most wrapped functions do, this is invisible.
Practice problems¶
Try these without looking back at the page:
- Write a function
Memoize(f func() int) func() intthat returns a function that runsfexactly once and caches the result. Usesync.OnceValue. - Write a
type Server structwhoseClose() erroris safe to call any number of times. Usesync.OnceValue[error]for the underlying close. - Write a
LoadOrFailpackage-level function that reads/etc/myapp/config.json, parses it as JSON into aConfig, and panics on any error. Subsequent calls should return the same*Config. Usesync.OnceValue[*Config]. - Demonstrate that calling a
sync.OnceFunc-wrapped panicking function from three goroutines causes all three to recover the same panic value. - Replace this old code with the new helpers:
var (
dbOnce sync.Once
db *sql.DB
dbErr error
)
func DB() (*sql.DB, error) {
dbOnce.Do(func() {
db, dbErr = sql.Open("postgres", dsn)
})
return db, dbErr
}
- Write a test that verifies your
Memoizefrom problem 1 calls the underlyingfexactly once even when invoked from 100 concurrent goroutines.
Solutions in tasks.md.