sync.Once — Junior Level¶
← Back to sync.Once
Table of Contents¶
- Introduction
- Prerequisites
- Glossary
- Core Concepts
- Real-World Analogies
- Mental Models
- Pros & Cons
- Use Cases
- Code Examples
- Coding Patterns
- Clean Code
- Product Use / Feature
- Error Handling
- Security Considerations
- Performance Tips
- Best Practices
- Edge Cases & Pitfalls
- Common Mistakes
- Common Misconceptions
- Tricky Points
- Test
- Tricky Questions
- Cheat Sheet
- Self-Assessment Checklist
- Summary
- What You Can Build
- Further Reading
- Related Topics
- Diagrams & Visual Aids
Introduction¶
Focus: "How do I make sure this expensive setup runs exactly once, no matter how many goroutines hit it at the same time?"
sync.Once is a tiny, zero-configuration primitive that solves one of the most common problems in concurrent Go programs: running a piece of code exactly once, even when many goroutines race to invoke it. The whole API is a single method:
You declare a sync.Once value, you call once.Do(f) from any number of goroutines, and the runtime guarantees that:
- The function
fruns exactly once. - All concurrent callers of
Doblock until the first call returns. - After
Doreturns, every caller is guaranteed to see all writes thatfperformed (the "happens-before" rule).
That third point is the quiet magic. Without it, a singleton built with Once could compile but expose a half-initialised object to a racing goroutine. With Once, the moment Do returns to any goroutine, the work is done and visible.
After reading this file you will:
- Know what
sync.Onceis, whatDodoes, and what it does not do. - Be able to write a thread-safe lazy singleton in five lines.
- Know why
defer once.Do(...)is almost always a bug. - Recognise the "retry after error" anti-pattern.
- Know about Go 1.21's
OnceFunc,OnceValue,OnceValues(a brief introduction; middle level digs deeper). - Have a feel for when
Onceis overkill and wheninit()would be cleaner.
You do not need to understand the internal done uint32 flag, the mutex slow path, or the memory model rules yet. Those come in the senior and professional levels. This file is about the moment you write once.Do(f) and trust the runtime to do its job.
Prerequisites¶
- Required: A Go installation, 1.18 or newer (1.21+ recommended to use
OnceFunc,OnceValue,OnceValues). Check withgo version. - Required: Comfort with
func()literals — the argument toDois a function value, often a closure. - Required: Basic awareness of goroutines and the data-race problem. If you have never spawned a goroutine, read the goroutines section first.
- Helpful: Familiarity with
sync.Mutexandsync.WaitGroup.Oncelives next to them in the same package and shares the philosophy of "tiny primitive, big leverage." - Helpful: Some exposure to lazy initialisation in another language — the Java
synchronizedblock, Python's module import lock, C++'sstd::call_once. They all solve the same problem;Onceis Go's answer.
If you can write var m sync.Mutex; m.Lock(); ...; m.Unlock() and you know what a closure is, you are ready.
Glossary¶
| Term | Definition |
|---|---|
sync.Once | A struct in the standard library sync package that ensures a function passed to Do runs exactly once. The zero value is ready to use. |
Do(f func()) | The only method. Calling once.Do(f) runs f exactly once across all goroutines that share this once value. Subsequent calls are no-ops. |
f | The function argument to Do. It takes no parameters and returns nothing. Wrap a more complex call in a closure to capture state. |
| Lazy initialisation | Deferring the construction of a value until the first time it is used, rather than at program startup. Once is the standard tool. |
| Singleton | A value that exists in exactly one instance for the lifetime of the program. Often built with Once + a package-level variable. |
| Happens-before | The Go memory model guarantee that operations in f are visible to all callers after Do returns. Without it, lazy singletons would expose half-built objects. |
OnceFunc (Go 1.21+) | A helper that wraps a func() so it can only run once. Returns a new function with the same signature. |
OnceValue[T] (Go 1.21+) | Wraps a func() T. The first call runs the function and caches the return value. All subsequent calls return the cached value. |
OnceValues[T1, T2] (Go 1.21+) | Like OnceValue but for functions returning two values, typically a value plus an error. |
init() | A special Go function that runs once at package load time, before main. An alternative to Once when the initialisation can happen unconditionally at startup. |
Core Concepts¶
Do runs the function exactly once¶
The contract is simple but precise:
After the first call to once.Do(setup) returns, setup has run, and every subsequent call returns immediately without running setup. The same is true across goroutines: if 100 goroutines simultaneously call once.Do(setup), exactly one runs setup, and the other 99 block until that one returns.
package main
import (
"fmt"
"sync"
)
var (
once sync.Once
config string
)
func loadConfig() {
fmt.Println("loading config (expensive)")
config = "loaded"
}
func main() {
once.Do(loadConfig) // prints "loading config..."
once.Do(loadConfig) // no output
once.Do(loadConfig) // no output
fmt.Println(config) // "loaded"
}
Do blocks concurrent callers until f returns¶
Imagine ten goroutines all calling once.Do(f) at exactly the same instant. The runtime picks one — the "winner" — and runs f on its goroutine. The other nine are parked on a mutex until f finishes. Once it does, they all wake up and return from Do, having done no work themselves.
This blocking behaviour is essential. Without it, a caller could see Do "return" before f had finished, and could start using a half-initialised value. With it, every caller sees a fully initialised result.
Once is single-use¶
The contract is: "exactly once for the lifetime of this sync.Once value." There is no reset. Even if f panics, Once considers itself "done." You cannot retry by calling Do again — the next call is a no-op.
var once sync.Once
once.Do(func() { panic("boom") }) // panics
once.Do(func() { ... }) // does NOT run; once is "done"
This is the most important behavioural detail of Once and the source of one of the most common bugs. We will return to it.
Once is the zero value, no constructor needed¶
sync.Once{} is ready to use. There is no NewOnce(), no constructor, no setup. Declare it at package level, in a struct field, or as a local — the zero value works.
A sync.Once declared in a function frame, though syntactically legal, is almost always wrong: each call creates a fresh Once, so "exactly once" means "exactly once per call," which is just "every time."
The function passed to Do takes no arguments and returns nothing¶
The signature of Do is:
f is a func(). No parameters, no return. To pass arguments or get a return value, use a closure:
Or, in Go 1.21+, use OnceValue (covered at middle level):
load := sync.OnceValue(func() *Config {
return parseConfig(path)
})
cfg := load() // expensive on first call, cheap on every subsequent call
Real-World Analogies¶
The hotel key card desk¶
You arrive at a conference and walk to the desk. There is one box of key cards. The clerk asks for your name. If your group has not yet been issued cards, the clerk opens the box, prints the cards, and hands them out — to you and to everyone else from your group who is waiting in line. If your group already has cards, the clerk just hands you yours from the stack. The box opens once. After that, every request is a quick lookup.
Once is the box. Do(loadKeys) opens it. Every Do afterwards is a no-op.
The shared kettle in a small office¶
Five colleagues all want tea. The first one to reach the kettle fills it and presses the button. The other four arrive and see the kettle is already boiling — they wait. When the water is ready, all five pour. The kettle was filled exactly once; everyone got their tea.
Once is the kettle. The first goroutine fills it. The others wait. After the water is poured, no one tries to fill it again — the kettle stays "done" for the rest of the day.
The classroom whiteboard¶
A teacher walks into a classroom and writes the day's agenda on the board. Twenty students walk in over the next ten minutes; the teacher does not rewrite the agenda each time. Whoever arrives reads what is already there.
Once.Do is the teacher writing on the board. The act happens once. All readers see the same result.
The "Have you seen the safety video?" sticker¶
An aircraft mechanic puts a sticker on the wing once each maintenance cycle. Every technician who works on the plane that cycle sees the sticker; none of them puts a new one on. At the start of the next cycle, a fresh Once is created — but during this cycle, the sticker is the sticker.
Once belongs to a value lifetime. Different Once values are different cycles.
Mental Models¶
Model 1: "A flag with a barrier"¶
Imagine a boolean done and a mutex mu:
That is the entire idea. The first goroutine to find done false takes the mutex, sees that it really is false, runs f, and sets done to true. Every later goroutine sees done = true and returns immediately. This pattern is called "double-checked locking" and Once is its idiomatic Go expression.
(The actual implementation uses an atomic load for the first check, so most callers never touch the mutex. We cover that in the professional level.)
Model 2: "A latch that only opens once"¶
A latch starts closed. The first caller pushes the handle, the latch opens, the work behind it runs. The latch is now open forever. No one needs to push the handle again. The work is visible to everyone.
Model 3: "A promise that resolves once"¶
If you have used promises in JavaScript or futures in Java, Once is the synchronous Go cousin: the first call to Do resolves the promise; everyone who awaits the promise sees the same result, no matter how many awaits happen.
Model 4: "A no-arg memoised function"¶
once.Do(f) is essentially "memoise f for a function that takes no arguments." There is one slot. The slot is filled on the first call. After that, every call is a lookup of "is the slot filled? yes? return."
In Go 1.21+, sync.OnceValue makes this view literal: it takes a func() T and returns a func() T that runs the original at most once and caches the return value.
Pros & Cons¶
Pros¶
- Trivial API. One method, no constructor, the zero value works. The cost of using
Oncecorrectly is two lines of code. - Cheap on the fast path. After
fhas run, every subsequent call toDois an atomic load and a branch — typically a few nanoseconds. - Correct memory ordering. The Go memory model guarantees that everything
fwrites is visible to every later caller. You do not need to add fences yourself. - No goroutine leak. Unlike a "background initialiser" goroutine,
Oncerunsfon the calling goroutine. There is nothing to clean up. - Composable. A
sync.Oncelives happily in a struct field, package variable, or local. You can have manyOncevalues in the same program — one per resource to be lazily initialised.
Cons¶
- No reset.
Onceis single-use for the life of the value. Ifffails, you cannot try again with the sameOnce. (You can create a newOnce, but that defeats sharing.) - No error reporting.
Do(f func())isfunc()— no return value. To propagate an error, you must store it in a captured variable or useOnceValues(1.21+). - Panic counts as "done." If
fpanics,Onceis permanently in the "done" state. Subsequent calls are no-ops. This is by design but surprising. - Recursive
Dodeadlocks. Callingonce.Dofrom inside the function passed toonce.Dodeadlocks the goroutine (the inner call waits on a mutex held by the outer). - Easy to misuse. Beginners use
Oncefor retry, for resettable initialisation, for "run at most every N seconds" — none of which it does. - No interface.
sync.Onceis a concrete struct. You cannot mock it for tests; you wrap it instead.
Use Cases¶
| Scenario | Why sync.Once helps |
|---|---|
| Lazy singleton (database connection, HTTP client, parser) | One global instance, built on first use, shared by all callers. |
| Expensive one-time setup that may never run | If the code path is never hit, the setup never happens. Unlike init(), which always runs. |
| Per-struct lazy field | A struct that builds an expensive cache on first method call. |
| Closing a channel exactly once | once.Do(func() { close(ch) }) makes "close the channel" idempotent and race-free. |
| One-time logging | Print a deprecation warning on the first use of an API, not every use. |
| One-time runtime feature detection | Probe the OS for a capability on first request, cache the result. |
| Scenario | Why sync.Once does not help |
|---|---|
| Retry a failing initialisation | Once does not reset. After failure, Do is a no-op forever. |
| Reset state between tests | The whole point of Once is that it cannot be reset. Use a fresh Once, or a non-Once mechanism. |
| Run initialisation periodically | Once runs once. For periodic work, use time.Ticker. |
| Initialise per request or per user | A package-level Once runs once for the whole process. For per-user work, use a sync.Map keyed by user ID. |
| Initialise from imports (no concurrency involved) | init() is cleaner: no boilerplate, runs eagerly at program start. |
Code Examples¶
Example 1: The minimal singleton¶
package main
import (
"fmt"
"sync"
)
var (
once sync.Once
instance *Server
)
type Server struct {
Addr string
}
func GetServer() *Server {
once.Do(func() {
instance = &Server{Addr: ":8080"}
fmt.Println("server created")
})
return instance
}
func main() {
s1 := GetServer() // prints "server created"
s2 := GetServer() // prints nothing
fmt.Println(s1 == s2) // true — same pointer
}
This is the classic Go singleton. The Once guards the assignment to instance. Every caller gets the same *Server.
Example 2: Race-free concurrent access¶
package main
import (
"fmt"
"sync"
)
var (
once sync.Once
config map[string]string
)
func loadConfig() {
fmt.Println("loading config")
config = map[string]string{"env": "prod"}
}
func get(key string) string {
once.Do(loadConfig)
return config[key]
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(get("env"))
}()
}
wg.Wait()
}
loadConfig prints once, no matter how many goroutines call get. There is no race on config because the happens-before guarantee of Once means every reader sees the map after it has been written.
Example 3: Once inside a struct¶
type LazyClient struct {
once sync.Once
client *http.Client
}
func (l *LazyClient) Get(url string) (*http.Response, error) {
l.once.Do(func() {
l.client = &http.Client{Timeout: 5 * time.Second}
})
return l.client.Get(url)
}
Each LazyClient has its own Once. The http.Client is built the first time Get is called on a given LazyClient. Different LazyClient instances have independent Once values and will each build their own client.
Example 4: Closing a channel exactly once¶
type Service struct {
closeOnce sync.Once
done chan struct{}
}
func NewService() *Service {
return &Service{done: make(chan struct{})}
}
func (s *Service) Stop() {
s.closeOnce.Do(func() {
close(s.done)
})
}
Closing an already-closed channel panics. Wrapping the close in a Once.Do makes Stop safe to call any number of times from any number of goroutines.
Example 5: One-time deprecation warning¶
var deprecationOnce sync.Once
func OldAPI() {
deprecationOnce.Do(func() {
log.Println("WARNING: OldAPI is deprecated, use NewAPI instead")
})
// ... real work ...
}
The warning prints the first time OldAPI is called and never again, no matter how often the function is invoked.
Example 6: Lazy parser¶
var (
parserOnce sync.Once
parser *regexp.Regexp
)
func ExtractIDs(s string) []string {
parserOnce.Do(func() {
parser = regexp.MustCompile(`id=(\d+)`)
})
return parser.FindAllString(s, -1)
}
The regex is compiled on the first call to ExtractIDs, then reused. If the function is never called, the regex is never compiled.
Example 7: The "wrong" pattern that beginners try¶
// BAD — defer once.Do does not do what you think
func Setup() {
var once sync.Once
defer once.Do(cleanup) // local Once; nothing else ever calls it
work()
}
A sync.Once declared inside a function is local. The defer runs cleanup exactly once... per call to Setup. That is the same as defer cleanup(). Once only buys you concurrency safety when it is shared across goroutines.
Example 8: Once for module-level test setup¶
var setupOnce sync.Once
func setupTestDB() {
setupOnce.Do(func() {
// expensive: spin up a test database
os.Setenv("DB_URL", spawnTestDB())
})
}
func TestUserCreate(t *testing.T) { setupTestDB(); /* ... */ }
func TestUserDelete(t *testing.T) { setupTestDB(); /* ... */ }
func TestUserList(t *testing.T) { setupTestDB(); /* ... */ }
If tests run in parallel (-parallel), only the first to reach setupTestDB actually spawns the database. The others wait, then proceed. Cleaner than TestMain if you have a handful of test files.
Example 9: Using OnceValue (Go 1.21+)¶
import "sync"
var loadConfig = sync.OnceValue(func() *Config {
return parseConfig("/etc/app.yaml")
})
func handler() {
cfg := loadConfig()
// ...
}
No package-level variable, no manual Once. OnceValue returns a function with the same return type. First call runs the body; every subsequent call returns the cached value. This is covered in depth at middle level.
Example 10: Combining Once with errors¶
var (
initOnce sync.Once
initErr error
handle *DB
)
func GetDB() (*DB, error) {
initOnce.Do(func() {
handle, initErr = openDB("...")
})
return handle, initErr
}
Once itself does not return an error, so we capture both the value and the error in package variables. The first caller does the work; everyone else sees the same handle and initErr. Note: this still does not allow retry — if initErr is non-nil, it stays that way forever.
Coding Patterns¶
Pattern 1: Lazy package-level singleton¶
var (
once sync.Once
inst *Thing
)
func Instance() *Thing {
once.Do(func() {
inst = newThing()
})
return inst
}
The most common shape. Three lines of declarations, one accessor.
Pattern 2: Lazy struct field¶
type Service struct {
once sync.Once
cache map[string]string
}
func (s *Service) Lookup(k string) string {
s.once.Do(func() {
s.cache = buildCache()
})
return s.cache[k]
}
Each Service has its own Once. Different services initialise independently.
Pattern 3: Idempotent close / cleanup¶
type Resource struct {
closeOnce sync.Once
f *os.File
}
func (r *Resource) Close() error {
var err error
r.closeOnce.Do(func() {
err = r.f.Close()
})
return err
}
Note: subsequent Close calls return nil, not the original error. That is usually fine, but be aware.
Pattern 4: Once with error capture¶
var (
once sync.Once
val Result
err error
)
func Get() (Result, error) {
once.Do(func() {
val, err = compute()
})
return val, err
}
Both result and error are captured. Every caller sees the same pair.
Pattern 5: OnceValue for cleaner code (Go 1.21+)¶
One line replaces the three-variable pattern. See middle level for full coverage.
Clean Code¶
- Place
Oncenext to the value it guards. Async.Oncedeclared three pages away from the variable it protects is hard to follow. Group them. - Name the accessor for what it returns, not how it works.
GetDB()is fine;LazyInitOnce()is not. The caller does not care that you usedOnce. - Use
Oncefor state, not actions with side effects you might want to repeat. "Connect to the database once" is fine. "Send a welcome email once" is suspicious — what if it failed? - Document the panic-equals-done behaviour where it matters. If your initialiser can panic, leave a comment explaining that the
Oncebecomes permanently "done." - Prefer
OnceValue(1.21+) over manualvar+sync.Onceboilerplate when the initialiser returns a value.
Product Use / Feature¶
| Product feature | How sync.Once delivers it |
|---|---|
| Database connection pool | Once builds the pool on first query; later queries reuse it. |
| Feature-flag client | The flag client connects to its backend lazily on first flag read. |
| Metric registry | A Prometheus registry is wired up once and shared across handlers. |
| Embedded asset loader | A static config or template is parsed once and cached. |
| TLS certificate loader | The PEM file is read and parsed on the first HTTPS request. |
| Background ticker | A monitoring goroutine is launched on first call to a "report" function. |
| Logger initialisation | A structured logger is configured the first time log.Info is called. |
Error Handling¶
sync.Once.Do(f) does not return an error. The function f itself returns nothing. To report failure, you have three options:
1. Capture the error in a closure¶
var (
once sync.Once
err error
val *Thing
)
func Get() (*Thing, error) {
once.Do(func() {
val, err = build()
})
return val, err
}
Simple and idiomatic. Every caller sees the same (val, err). The downside is that you cannot retry — once the error is captured, it sticks.
2. Use OnceValues (Go 1.21+)¶
var get = sync.OnceValues(func() (*Thing, error) {
return build()
})
func Get() (*Thing, error) { return get() }
Cleaner. Same semantics: the error is computed once and remembered.
3. Defer the decision: wrap with retry logic outside¶
var (
once sync.Once
val *Thing
err error
)
func Get() (*Thing, error) {
once.Do(func() { val, err = build() })
if err != nil {
return nil, err // caller may retry the *operation*, not the init
}
return val, nil
}
This is the same as option 1; what changes is the calling pattern. If retry is required, do it with a different mechanism — see "Anti-patterns" below.
Panic in f¶
If f panics, the panic propagates to the caller of Do. The Once is marked done. Subsequent calls are no-ops. To survive a panic and report it as an error:
once.Do(func() {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("init panicked: %v", r)
}
}()
val = build() // may panic
})
Now the error variable carries the panic information and the program does not crash. Still no retry.
Security Considerations¶
- One-time secret loading. If you load a private key with
Once, make sure the file path is not derived from user input. AOnce-loaded value lives for the life of the process; loading the wrong key once means using it for every future request. - Don't lazy-initialise something the user could request to skip. A request that triggers a
Once.Do(loadSecrets)may be the first to touch sensitive memory. Build into your threat model that "first request" is when secrets are loaded. - Panic in init can become DoS. If
fpanics on bad config and the panic is recovered, theOnceis done but the value is nil. Every later request returns nil — a permanent outage triggered by the first malformed input. Validate config eagerly. - TOCTOU on file content.
Oncereads at first use, not at startup. If the file changes between program start and first read, you read the new content. This may surprise you if you expected start-time semantics.
Performance Tips¶
- The fast path is cheap. After
fhas run,Dois roughly an atomic load + branch — single-digit nanoseconds on modern hardware. Calling it on every request is fine. - Avoid putting
Onceon a hot loop's critical path if you can useinit()instead.init()runs once at startup, with no per-call check. If the value can be built eagerly, eager is faster. - Do not inline a
Onceper request. Async.Once{}allocated per HTTP request is a newOnce; it will runfevery request. The whole point is sharing. - Watch out for the slow path under contention. If 1000 goroutines hit a cold
Onceat the same time, 999 of them block on the mutex insideDo. That is fine, but it is a brief stampede. Pre-warming with a synchronousGet()at startup avoids it. - For "build once, read many" with a single value, consider
atomic.Pointer. It can be even faster thanOnceif you already have the value at startup. We cover this in the optimisation page.
Best Practices¶
- Use
Oncefor "exactly once" semantics in concurrent code. That is its job. Anything else is overreach. - Declare the
Oncenext to the variable it guards. Reading code is easier when the relationship is local. - Always pass
Onceby pointer. Copying async.Once(e.g., passing by value into a function) breaks "exactly once."go vetwarns; heed it. - Do not call
once.Dofrom inside the function passed toonce.Do. Deadlock. - Capture errors via a closure variable, not a return.
Docannot return one. - Prefer
OnceValue/OnceValuesin Go 1.21+ for value-returning initialisers. Less boilerplate, same semantics. - Do not use
Oncefor retry. If init can fail and should be retried, you need a different design (Mutex-guarded "try again on error" logic, or anatomic.Pointerswap). - Document panic behaviour. If
fcan panic, future maintainers need to know that theOnceis permanently dead afterwards. - Reset by replacement, not by mutation. If you really need to re-initialise, swap to a new
Once. (And then think hard about whether the design is right.) - In tests, use
t.Parallel()-safe patterns or freshOnceper test fixture. Package-levelOnceis shared across all tests.
Edge Cases & Pitfalls¶
defer once.Do(f) does not run f lazily¶
You probably wanted once.Do(setup); work(). The defer form runs setup at the end of handler, defeating the lazy-init intent.
Copying a Once¶
type T struct {
once sync.Once
}
func bad(t T) { t.once.Do(setup) } // BUG: t is a copy
func good(t *T) { t.once.Do(setup) } // pointer, shared Once
Passing a struct that contains a sync.Once by value gives the callee a fresh Once. Different callers see different Once instances; setup may run repeatedly. go vet warns about this.
Once in a slice element¶
type Slot struct { once sync.Once; val int }
slots := make([]Slot, 100)
slots[0].once.Do(...) // fine, slice elements have stable addresses
// but: for _, s := range slots { s.once.Do(...) } // BUG — s is a copy
Range variables copy. Index by position when calling methods on Once.
Recursive Do¶
The outer Do holds the mutex. The inner Do tries to take it. The goroutine is stuck. Detected at runtime by the deadlock detector if no other goroutines are runnable.
Once with goroutines spawned inside f¶
f returns after the go background() statement, not after background() finishes. The Once is marked done as soon as f returns; if background() has not yet run, callers of Do after this point will not wait for it. Use WaitGroup inside f if the goroutine must finish before Do is considered complete.
Once after a panic¶
once.Do(func() { panic("nope") })
// ... recovered higher up ...
once.Do(setup) // does NOT run; once is "done"
A panic counts as completion. The most common surprise.
Forgetting that Once has no error return¶
f must be func(), not func() error. Capture errors via closure variables.
Common Mistakes¶
| Mistake | Fix |
|---|---|
Using Once to retry a failing initialiser | Use a Mutex + nil-check pattern that can retry, or atomic.Pointer swap. |
Allocating a new Once per call | Move it to package level or a long-lived struct field. |
defer once.Do(setup) | Drop the defer; call once.Do(setup) at the start of the function. |
Calling once.Do recursively | Pre-compute the inner work, or restructure to avoid the cycle. |
Capturing the wrong variable in the f closure | Read the closure carefully; consider passing values explicitly via a wrapper function. |
Copying a struct containing sync.Once | Always pass by pointer; let go vet warn you. |
Expecting Do to return an error | Use a closure variable, or OnceValues (1.21+). |
Mixing lazy Once with eager init() for the same value | Pick one. Eager is simpler; lazy is for code paths that may not run. |
Resetting Once by hand | You cannot. The design says no. Find a different abstraction. |
Calling Do on a *Once that is nil | Panics. Always have a non-nil Once. |
Common Misconceptions¶
"
Onceruns the function in the background." — No. It runsfon the calling goroutine, synchronously."
Oncecan be reset." — No. There is noResetmethod. By design."
Once.Doreturns aboolsaying whether it ran." — No. It returns nothing. If you need to know, set a captured boolean insidef."A panic in
flets me retry." — No. Panic counts as completion. TheOnceis done."
Onceis just a mutex." — It is more. It also provides a happens-before guarantee, so reads afterDoare race-free without additional synchronisation."I can pass a
Onceby value to a function." — Only the first call uses the original; the copy is independent.go vetwarns."
Onceworks across processes." — No.Onceis in-process. Different processes have differentOnceinstances."
OnceFuncis the same asOnce." — Related but different.OnceFunc(f)returns a new function. The wrapper has its own internalOnce. Calling the wrapper many times is like callingonce.Do(f)many times. Middle level covers it.
Tricky Points¶
Once is "exactly once" per value¶
Two different sync.Once values are independent. If you accidentally have two of them where you meant one, you will see f run twice. This is why placement of the Once declaration matters: package level for global init, struct field for per-instance init.
The fast path is an atomic load, not a mutex¶
After f has run, Do is essentially:
That is a few nanoseconds. The mutex is only entered on the slow path (the first time, and concurrent racers waiting for the first time to finish).
f is called even if Do is later called from a different goroutine¶
The "winner" of the race may be goroutine A. Goroutine B calls Do later — f does not re-run, but B sees all of A's writes. This is the happens-before guarantee.
The Once does not "remember" which f ran¶
Once does not key by function. It is a single flag. If you need different functions to each run once, use different Once values.
Once does not guard f's side effects from being undone¶
If f allocates a *Thing and assigns it to a global, nothing prevents another goroutine from later setting that global to nil. Once only governs whether f runs again; what f does is your responsibility.
OnceFunc panics propagate on every call (Go 1.21+)¶
The wrapper returned by sync.OnceFunc re-panics on every subsequent call if the first call panicked. This is different from raw Once, where subsequent calls are silent no-ops. Middle level details this.
Test¶
package once_test
import (
"sync"
"sync/atomic"
"testing"
)
func TestDoRunsExactlyOnce(t *testing.T) {
var once sync.Once
var n int32
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
once.Do(func() {
atomic.AddInt32(&n, 1)
})
}()
}
wg.Wait()
if n != 1 {
t.Fatalf("expected 1, got %d", n)
}
}
func TestDoVisibility(t *testing.T) {
var once sync.Once
var value int
once.Do(func() { value = 42 })
if value != 42 {
t.Fatalf("expected 42, got %d", value)
}
}
func TestDoAfterPanic(t *testing.T) {
var once sync.Once
defer func() { _ = recover() }()
func() {
defer func() { _ = recover() }()
once.Do(func() { panic("boom") })
}()
ran := false
once.Do(func() { ran = true })
if ran {
t.Fatal("Do ran after panic; expected no-op")
}
}
Run with the race detector:
The race detector confirms that the writes in f are visible to readers without additional synchronisation, validating the happens-before claim.
Tricky Questions¶
Q. What does this print?
A. a. The second Do is a no-op because once is already done. It does not call the new function.
Q. Why is this broken?
A. The Once is local to Setup. Each call to Setup creates a fresh Once, so load runs every time. Move once to package scope (or a struct field) to share it.
Q. What happens here?
A. Deadlock. The outer Do holds an internal mutex; the inner Do waits for it.
Q. What does this print?
var once sync.Once
defer func() { fmt.Println(recover()) }()
once.Do(func() { panic("oops") })
fmt.Println("after")
A. oops. The panic in f propagates out of Do, so after is never reached. The deferred recover catches it.
Q. Why is this leaking memory?
type Cache struct {
once sync.Once
data map[string]Big
}
func (c Cache) Get(k string) Big { // c is copied
c.once.Do(func() { c.data = load() })
return c.data[k]
}
A. c is passed by value. Each call gets a fresh copy with its own Once, so load runs every call and the map is built from scratch every time. Use *Cache instead.
Q. When would init() be better than Once?
A. When the initialisation must happen and is cheap enough not to harm startup time. init() runs before main, with no per-call overhead and no concurrency concerns. Use Once only when init may be skipped or is too expensive to do eagerly.
Cheat Sheet¶
// Declare
var once sync.Once
// Run f exactly once
once.Do(func() {
// setup
})
// Lazy singleton
var (
once sync.Once
inst *Thing
)
func Instance() *Thing {
once.Do(func() { inst = newThing() })
return inst
}
// Idempotent close
type R struct { closeOnce sync.Once; f *os.File }
func (r *R) Close() error {
var err error
r.closeOnce.Do(func() { err = r.f.Close() })
return err
}
// Go 1.21+ helpers
var loadConfig = sync.OnceValue(func() *Config { return parse() })
cfg := loadConfig() // build on first call, cached after
var loadDB = sync.OnceValues(func() (*DB, error) { return open() })
db, err := loadDB()
// Avoid:
defer once.Do(f) // does not lazy-init; just defers
once.Do(func(){ once.Do(g) }) // DEADLOCK
func F(t T) // copies sync.Once inside T; use *T
Self-Assessment Checklist¶
- I can explain
sync.Oncein one sentence. - I can write a lazy singleton in five lines without looking it up.
- I know why
defer once.Do(f)is almost always wrong. - I know that copying a
Oncedefeats it, and thatgo vetwarns. - I know that a panic in
fmakesOncepermanently "done." - I know that
Dodoes not return an error and how to capture one via closure. - I know what
OnceFunc,OnceValue,OnceValuesare (introduced in Go 1.21). - I know that recursive
Dodeadlocks. - I know when to use
init()instead. - I have written a
Once-protected channel close.
Summary¶
sync.Once is the smallest synchronisation primitive in Go that solves a big problem: "run this exactly once, no matter how many goroutines race to call it." The API is a single method, Do(func()). The implementation is a fast-path atomic load plus a slow-path mutex. The guarantee is "exactly once, with happens-before visibility."
You use Once to build lazy singletons, idempotent close methods, one-time warnings, and on-first-use caches. You do not use it for retry, for periodic work, for per-request init, or for anything that needs to reset. A panic in f marks the Once done forever — surprising, but by design.
Go 1.21 added OnceFunc, OnceValue, and OnceValues as ergonomic wrappers. Most new code should use them when the initialiser returns a value. The classic sync.Once still works and remains the right choice for action-only initialisers.
Next, at middle level, we look at the 1.21 helpers in depth, how to combine Once with errors and context, and the classic patterns for swapping in a different abstraction when retry or refresh is needed.
What You Can Build¶
After mastering this material:
- A package-level database handle with thread-safe lazy connection.
- A configuration loader that parses YAML on first request and caches the result.
- A safe
Close()method that is idempotent across many callers. - A one-time deprecation warning that fires once per process.
- A test helper that spins up a shared test database on the first parallel test.
- A regex-using utility function that compiles its pattern lazily.
- A feature-flag client that connects on first read, never on startup.
Further Reading¶
- The
sync.Oncedocumentation: https://pkg.go.dev/sync#Once sync.OnceFunc,OnceValue,OnceValues(Go 1.21+): https://pkg.go.dev/sync#OnceFunc- The Go memory model: https://go.dev/ref/mem
- The Go Blog — Go 1.21: New sync helpers: https://go.dev/doc/go1.21
- Effective Go — Concurrency: https://go.dev/doc/effective_go#concurrency
- Russ Cox, The Go Memory Model: https://research.swtch.com/gomm
- Dave Cheney, Why is a Goroutine's stack infinite? (background on runtime primitives): https://dave.cheney.net/2013/06/02/why-is-a-goroutines-stack-infinite
Related Topics¶
sync.Mutex— the lock thatOnceuses internally on the slow pathsync.WaitGroup— sibling primitive for "wait for N goroutines"atomic.Pointer— alternative for "build once, swap atomically"sync.Map— for per-key initialisation across many keysinit()— language-level "run once at package load"context.Context— cancellation across goroutine trees
Diagrams & Visual Aids¶
Lifecycle of a Once¶
created (zero value) done = 0
|
v
first Do(f) call done = 0 -> running
|
v
f executes work happens
|
v
Do returns done = 1
|
v
subsequent Do calls return immediately (no-op)
Concurrent callers¶
goroutine 1 ---> once.Do(f) ---+
|--- one of them runs f
goroutine 2 ---> once.Do(f) ---+ others block on mutex
|
goroutine 3 ---> once.Do(f) ---+ after f returns, all wake
and exit Do
Fast path vs slow path¶
once.Do(f):
|
v
load(done)
|
+-- done == 1? --> return [FAST PATH: ~2ns]
|
+-- done == 0? --> take mutex [SLOW PATH]
load(done) again
|
+-- now 1? --> release, return
|
+-- still 0? --> run f
store(done = 1)
release mutex
return
Memory model visibility¶
goroutine A: goroutine B:
once.Do(func() {
x = 42 once.Do(...) // returns
}) print(x) // sees 42
// x = 42 stored
The Do return on goroutine B happens-after the Do return on goroutine A, so all writes from A's f are visible to B.
Two Once values are independent¶
If you want a single execution across two code paths, share the same Once.