Skip to content
gohacks.dev
Concurrency

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.

7 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

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:

main.goGo
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"])
}
Text
loading config (runs once)
production

Five 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.

main.goGo
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)
}
Text
computing limit
1024 1024
opening database
db-handle <nil>
db-handle <nil>
attempting flaky init
connection refused
connection refused

OnceValue 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:

main.goGo
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() })
}
Text
Once #1      recovered: init failed
Once #2      recovered: <nil>
function ran 1 time(s)
OnceValue #1 recovered: boom
OnceValue #2 recovered: boom

With 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:

main.goGo
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)
	}
}
Text
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

NeedUse
Run setup once, no resultsync.Once or sync.OnceFunc
Compute a value once and share itsync.OnceValue
Compute a value and an error oncesync.OnceValues (error cached forever)
Retry until it worksmutex and flag
Always needed at startpackage 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.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.