A lot of expensive setup work in an online store happens exactly once over the lifetime of a process: parsing a config file, opening the database connection pool, building a discount-rules table from disk, loading the product taxonomy into memory. If several goroutines hit that setup path at the same time, you need a way to make sure only one of them does the work and the rest wait for it. sync.Once is the standard primitive for that job.
A lazy initializer is a function that runs the first time someone needs the resource, then caches the result for every later call. The naive version uses a boolean flag.
The flag protects against re-running the work inside a single goroutine, but five goroutines all read taxonomyLoaded as false at roughly the same moment, all walk past the early return, and all do the load. Worse, the reads and writes to taxonomyLoaded and taxonomy aren't synchronized, so this is a data race. go run -race flags it immediately.
You can try to fix it with a mutex.
This is correct, but every single call grabs and releases the lock forever, even years after the load happened. That's wasteful on a hot path. The "fix" most beginners try next is double-checked locking, and the most common version of it is broken.
The unsynchronized read of taxonomyLoaded is a data race per the Go memory model. Even if the read happens to return true, there's no guarantee the reader sees the fully populated taxonomy map. The reader could see taxonomyLoaded == true and still get a nil taxonomy. This pattern is the reason sync.Once exists.
sync.Once Doessync.Once has one exported method: Do(f func()). The contract is small.
f runs at most once, ever, for that particular Once value.Do runs f.Do while f is running blocks until f returns.f has returned, every subsequent call to Do returns immediately without invoking f again.f are visible to every goroutine that observes Do returning (the memory-model happens-before guarantee).The same example, rewritten with Once.
The "Loading taxonomy..." line prints once. The other four goroutines block inside Do until the first one returns, then they each fall through with no work to do. The taxonomy map is fully built by the time any goroutine observes Do returning, because the memory-model guarantee covers writes inside f.
The diagram captures the three roles. Goroutine 1 wins the race to run f. Goroutines 2 and 3 arrive while f is still running and block until it returns. Goroutine 4 arrives much later, sees the "done" state, and skips straight through without locking on the slow path.
Cost: After the first call, Do is essentially an atomic load and a branch. It's cheap enough to call on every hot-path request, so you don't have to cache the result somewhere else just to avoid Do's overhead.
The most common shape for sync.Once in real code is a package-level var holding the resource plus a package-level Once controlling its initialization. The exported accessor is a function that calls Do and returns the resource.
This is how you'd structure a product-taxonomy loader for an online store. The taxonomy is a tree of category names that probably lives in a JSON or YAML file on disk. The first call to Taxonomy() reads the file and builds the tree; every later call returns the cached value.
Three things are worth noting. The result lives in a package-level var so callers can read it after Do returns. The error is captured in another package-level var instead of being returned from inside the closure, because Do doesn't propagate return values. And the accessor returns both the value and the error, so callers can react to a failed load.
This last point matters more than it looks. If Do is called once and the file is missing, taxonomyErr gets set, taxonomyOnce flips to "done", and every later call to Taxonomy() returns the same error forever. That's intentional: sync.Once is for things that should genuinely happen once, not for things you'd want to retry.
The same shape works for any one-time setup: a discount-rules table, a database connection pool, a compiled regex, a parsed config file. Replace the file load with the appropriate work and the pattern stays identical.
There's a subtle promise in sync.Once that's easy to miss. Any writes the closure performs are guaranteed to be visible to any goroutine that observes Do returning. In Go-spec language, the completion of f happens-before the return of any Do call.
That sounds like a technicality, but it's the reason Once works at all. If the guarantee didn't exist, a goroutine could see Do return and then read a half-initialized resource, exactly the same bug that breaks naive double-checked locking. With the guarantee, every goroutine that sees Do return reads a fully populated resource.
The practical consequence: you don't need an extra sync.RWMutex around reads of a Once-initialized resource. The Once is the synchronization. Adding more locking around the read path is wasted work.
An aspect of sync.Once semantics worth knowing: if the function passed to Do panics, Once still flips to the "done" state. Subsequent calls to Do return immediately without retrying.
The first call to Do panics. The recover catches it, but the Once has already marked itself as done. The second and third calls do nothing, and the final Do with the "success" closure never runs either. loaded stays false.
What's wrong with this code?
The closure assumes that if openDB returns, the database is ready. But after a panic, dbOnce is marked done and every later call to openDB returns immediately without retrying. The next call to GetUser reads from a nil map (which returns the zero value for missing keys silently, hiding the bug). And if db were used as a real connection pool, the call to db.Query(...) on a nil pool would panic at runtime, far from the original failure.
Fix: capture the error inside the closure into a package-level variable, and have the accessor return it. Don't let "Once is done" be confused with "the resource initialized successfully".
Both calls see the same error. The state is honest: the init ran, it failed, every caller knows. If you want retry-on-failure semantics, that's a different primitive (a sync.Mutex plus a "loaded" flag, or a custom Lazy type), not sync.Once.
Cost: sync.Once is designed for "happens exactly once, ever". If your closure needs retry-on-failure behavior, use a sync.Mutex and a "loaded" flag, and reset the flag on failure.
A sync.Once value is single-use. There is no Reset method, no way to make it "forget" that it ran. In a single-goroutine program you can reassign once = sync.Once{} to swap in a fresh value, but doing that while other goroutines might be calling Do on it is a data race. If you genuinely need "re-initialize across goroutines", build it with sync.Mutex and a flag, or use a channel-based approach, or swap in a freshly built resource via atomic.Pointer. Don't try to recycle sync.Once.
OnceFunc, OnceValue, OnceValuesAdded in Go 1.21, the sync package added three helpers that wrap the Once.Do pattern into something cleaner. They each return a function. Calling that function repeatedly produces the same behavior as a manual Once.Do: the underlying work runs once, every later call returns the cached result, and concurrent calls during the first run block until it finishes.
The three helpers differ in what the wrapped function returns.
| Helper | Signature of input | What you get back | Use when |
|---|---|---|---|
sync.OnceFunc(f func()) | func() (no return) | func() you call repeatedly | The work has side effects only, no value to cache. |
sync.OnceValue[T](f func() T) | func() T | func() T returning the cached value | You want one cached value. |
sync.OnceValues[T1, T2](f func() (T1, T2)) | func() (T1, T2) | func() (T1, T2) returning both cached values | You want a value plus an error (or any two-value return). |
OnceFunc: Side-Effect InitializationOnceFunc is the right pick when the work doesn't produce a value, just a side effect. A common case is registering metrics, warming a logger, or printing a startup banner exactly once.
The registration line prints once, then placeOrder calls become normal work. Use this when the "thing to do" is fire-and-forget with no return value worth caching.
One thing to know about OnceFunc specifically: if the wrapped function panics on the first call, the returned function re-panics with the same value on every subsequent call. That's different from Once.Do, which marks itself done and silently returns on later calls after a panic. OnceFunc's behavior is usually what you want: callers can't ignore the failure by accident.
OnceValue: One Cached ValueOnceValue is the cleanest fit for the package-level singleton pattern when the resource is a single value with no error.
The "building discount table..." line prints once. Each call to loadDiscountTable() returns the same map. The package-level var loadDiscountTable = sync.OnceValue(...) shrinks the singleton pattern from "Once + var + accessor function" to one line.
OnceValues: Value Plus ErrorOnceValues is the right pick when the init can fail and you want callers to see the error every time, not just the first.
loadProductCatalog is a func() (map[string]string, error) that you can call from anywhere, every call gets the same (value, error) pair, and the load runs exactly once. If the closure returned an error, every later call would see that same error, which is the behavior you want.
Cost: OnceFunc, OnceValue, and OnceValues add no measurable overhead compared to managing sync.Once by hand. The choice between them is about readability, not performance.
The three helpers don't replace sync.Once. Plain sync.Once is still right when you're caching more than two values into package-level state, when the closure needs to capture and update multiple state variables, or when you're targeting Go versions older than 1.21. Otherwise: OnceFunc for side effects, OnceValue for one cached value, OnceValues for a value plus an error.
A short tour of the patterns you'll see in an online store codebase.
sync.OnceValue (or OnceValues if file errors matter) is the natural fit.sql.Open that adds retry logic, secret loading, or logging, called once per process. sync.Once plus a package-level *sql.DB.config.yaml parsed into a struct on the first call to Config(). OnceValues is the cleanest pick because parsing can fail.var compileEmailRegex = sync.OnceValue(func() *regexp.Regexp { return regexp.MustCompile(...) }).The rule of thumb: if the work needs to happen exactly once per process and several goroutines might trigger it concurrently, sync.Once (or one of the helpers) is the right primitive.
10 quizzes