The sync Once Family in Go and Lazy Initialization
Use sync.Once, OnceFunc, OnceValue and OnceValues for lazy initialization in Go, with what happens on panics and errors and when an init function is better.

Lazy initialization means doing a piece of work the first time it is needed, not at start-up. Reading a configuration file, compiling a regular expression, opening a connection pool: if the program may never need it, or if starting up fast matters, you defer the work. Concurrency makes the obvious version wrong. Two goroutines can both see "not initialised yet" and both do the work, or one can see a half-built value. sync.Once is the standard answer, and since Go 1.21 it has helpers that remove most of the code.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article covers the four forms, the cases they handle badly, and the choice against plain initialization. It belongs to the concurrency material and builds on mutexes. Everything ran on Go 1.25.6.
sync.Once
A Once has one method, Do. The first call runs the function; every later call returns without running it. Callers that arrive while the first is still running wait for it to finish, so when Do returns, the initialisation is complete and visible:
package main
import (
"fmt"
"sync"
)
var (
once sync.Once
config map[string]string
)
func loadConfig() {
fmt.Println("loading config (runs once)")
config = map[string]string{"mode": "production"}
}
func Config() map[string]string {
once.Do(loadConfig)
return config
}
func main() {
var wg sync.WaitGroup
for range 5 {
wg.Go(func() { _ = Config()["mode"] })
}
wg.Wait()
fmt.Println(Config()["mode"])
}loading config (runs once)
productionFive goroutines call Config at once. loadConfig runs exactly once, and the message prints once. All five see a fully built map, because Do establishes a happens-before relationship between the end of the function and the return of every Do call (the memory model). A Once must not be copied after first use, like a mutex, and go vet checks that.
OnceFunc, OnceValue and OnceValues
Go 1.21 added three wrappers that package the pattern: a function that runs another function once and remembers the answer.
package main
import (
"errors"
"fmt"
"sync"
)
var loadLimit = sync.OnceValue(func() int {
fmt.Println("computing limit")
return 1024
})
var openDB = sync.OnceValues(func() (string, error) {
fmt.Println("opening database")
return "db-handle", nil
})
var flaky = sync.OnceValues(func() (string, error) {
fmt.Println("attempting flaky init")
return "", errors.New("connection refused")
})
func main() {
fmt.Println(loadLimit(), loadLimit())
db, err := openDB()
fmt.Println(db, err)
db, err = openDB()
fmt.Println(db, err)
_, err = flaky()
fmt.Println(err)
_, err = flaky()
fmt.Println(err)
}computing limit
1024 1024
opening database
db-handle <nil>
db-handle <nil>
attempting flaky init
connection refused
connection refusedOnceValue takes a function returning one value and gives back a function that computes it on the first call and returns the cached value afterwards. OnceValues does the same for two results, which is the natural shape for (T, error). OnceFunc wraps a function with no results. They replace the usual trio of a variable, a Once and an accessor function with one declaration, and the value cannot be read before it is initialised because the only way to get it is to call the function.
Look at the last two lines: the error from flaky is cached. The second call prints connection refused without attempting the initialisation again, because the function ran once. That is correct behavior for these types and often not what you want, as the next section shows.
What a panic does
If the function panics, the types disagree on what comes next, and it is worth knowing before it surprises you:
package main
import (
"fmt"
"sync"
)
func try(name string, f func()) {
defer func() { fmt.Printf("%-12s recovered: %v\n", name, recover()) }()
f()
}
func main() {
var once sync.Once
runs := 0
try("Once #1", func() { once.Do(func() { runs++; panic("init failed") }) })
try("Once #2", func() { once.Do(func() { runs++ }) })
fmt.Println("function ran", runs, "time(s)")
get := sync.OnceValue(func() int { panic("boom") })
try("OnceValue #1", func() { get() })
try("OnceValue #2", func() { get() })
}Once #1 recovered: init failed
Once #2 recovered: <nil>
function ran 1 time(s)
OnceValue #1 recovered: boom
OnceValue #2 recovered: boomWith plain Once, the first call panics and the second does nothing: Do considers the call complete once f returns, and a panic counts as returning, so the initialisation is marked done although it failed, and the code after it will run on a half-initialised world. The wrapper types do better: OnceFunc and OnceValue remember the panic value and panic again with the same value on every call, so a failed initialisation cannot be mistaken for a successful one. If you use plain Once with a function that can panic, set a flag inside the function after the work succeeds and check it afterwards.
Errors, and the retry problem
Caching an error forever is the right choice when the failure is permanent, such as a malformed embedded config file. It is wrong when the failure is transient, such as a database that is not up yet. A service that tries to connect on first use, fails once during a restart and then returns "connection refused" for the rest of its life has been bitten by exactly this.
For retryable initialisation, use a mutex and a flag, and only record success:
package main
import (
"errors"
"fmt"
"sync"
)
type Lazy struct {
mu sync.Mutex
value string
ready bool
init func() (string, error)
}
func (l *Lazy) Get() (string, error) {
l.mu.Lock()
defer l.mu.Unlock()
if l.ready {
return l.value, nil
}
v, err := l.init()
if err != nil {
return "", err // not cached: the next call tries again
}
l.value, l.ready = v, true
return v, nil
}
func main() {
attempts := 0
l := &Lazy{init: func() (string, error) {
attempts++
if attempts < 3 {
return "", errors.New("not ready yet")
}
return "connected", nil
}}
for range 4 {
v, err := l.Get()
fmt.Printf("attempt %d: %q %v\n", attempts, v, err)
}
}attempt 1: "" not ready yet
attempt 2: "" not ready yet
attempt 3: "connected" <nil>
attempt 3: "connected" <nil>The first two calls fail and are retried, the third succeeds, and the fourth returns the cached value without calling init. The lock makes concurrent callers wait for an in-progress attempt, as Once does. The cost is that every call takes the mutex, even after success; if that matters, double-check with an atomic flag before locking, or store the ready value in an atomic.Pointer (atomics).
Once versus init versus a constructor
Lazy initialisation is a tool for optional work, not a default. Compare the alternatives.
Package init or a package-level variable initialiser runs at start-up, in dependency order, in one goroutine, with no synchronisation needed. It is the simplest correct choice when the value is always needed and cheap, and the right place to fail fast on a bad configuration (the init function). The costs are start-up time and the inability to take parameters.
A constructor called from main passes dependencies explicitly, builds them once and hands them down. It makes order and failure visible, it is easy to test, and it needs no globals. For anything with parameters or error handling, this beats a lazily initialised global.
sync.Once and friends earn their place when the value is expensive and often unneeded (a large lookup table used by one rarely called function), when initialisation depends on the first caller's context, or in a library that must not do work at import time.
A frequent misuse is wrapping a cheap value in Once out of habit. regexp.MustCompile at package level costs microseconds at start-up, and an OnceValue around it adds complexity for no gain. Reserve laziness for things you have measured.
Testing and resetting
A Once cannot be reset, which is its point and a nuisance for tests: the first test to touch the global initialises it for all the others. Two ways out. Keep the lazy value inside a struct that tests can construct fresh, so each test gets its own Once. Or accept that the global is initialised once per process and write tests that do not depend on its configuration. Resetting by assigning a new sync.Once{} is a race if anything else is running, and go vet flags the copy. Designing the type so the Once lives inside an object, not at package level, solves it.
Quick reference
| Need | Use |
|---|---|
| Run setup once, no result | sync.Once or sync.OnceFunc |
| Compute a value once and share it | sync.OnceValue |
| Compute a value and an error once | sync.OnceValues (error cached forever) |
| Retry until it works | mutex and flag |
| Always needed at start | package init or a constructor |
The documentation for each function is in the sync package, including the exact panic behavior, and the Go 1.21 release notes list the new helpers. For the spec's guarantees about package initialisation order, see package initialization.


