AlgoMaster Logo

sync.Once

Medium Priority14 min readUpdated June 6, 2026

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.

The Problem: Lazy Init Across Goroutines

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.

What sync.Once Does

sync.Once has one exported method: Do(f func()). The contract is small.

  • f runs at most once, ever, for that particular Once value.
  • The first goroutine to call Do runs f.
  • Every other goroutine that calls Do while f is running blocks until f returns.
  • After f has returned, every subsequent call to Do returns immediately without invoking f again.
  • All writes performed inside 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.

The Package-Level Singleton Pattern

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.

Memory-Model Guarantee

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.

Panics Count as "Done"

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.

Once Cannot Be Reset

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.

Go 1.21: OnceFunc, OnceValue, OnceValues

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

HelperSignature of inputWhat you get backUse when
sync.OnceFunc(f func())func() (no return)func() you call repeatedlyThe work has side effects only, no value to cache.
sync.OnceValue[T](f func() T)func() Tfunc() T returning the cached valueYou want one cached value.
sync.OnceValues[T1, T2](f func() (T1, T2))func() (T1, T2)func() (T1, T2) returning both cached valuesYou want a value plus an error (or any two-value return).

OnceFunc: Side-Effect Initialization

OnceFunc 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 Value

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

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

When Each Helper Fits

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.

Common Real-World Uses

A short tour of the patterns you'll see in an online store codebase.

  • Discount-rules table. Promo codes loaded from a JSON file on first validation call. sync.OnceValue (or OnceValues if file errors matter) is the natural fit.
  • Database connection pool. A wrapper around sql.Open that adds retry logic, secret loading, or logging, called once per process. sync.Once plus a package-level *sql.DB.
  • Product taxonomy. A category tree (Electronics > Phones > Smartphones) read from a config file at first use, cached for the rest of the process.
  • Application config. A config.yaml parsed into a struct on the first call to Config(). OnceValues is the cleanest pick because parsing can fail.
  • Compiled regex on a hot path. Compile once at first use rather than at package init: 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.

Quiz

sync.Once Quiz

10 quizzes