The single-setup problem
You are building a service that needs to load a massive configuration file. The file takes two seconds to parse and validate. Three different HTTP handlers might try to load it at the exact same moment when the server starts. You do not want to parse it three times. You do not want to race on a global variable. You want it loaded once, safely, and then available to everyone.
Go solves this with sync.Once. It is a concurrency primitive that guarantees a function runs exactly one time, no matter how many goroutines call it simultaneously. It wraps a mutex and a done flag. The first caller gets the lock, runs the function, marks it as done, and releases the lock. Every other caller sees the done flag and returns immediately. It is the standard way to do lazy initialization in Go without writing your own locking logic.
How sync.Once actually works
Under the hood, sync.Once holds two pieces of state: a mutex and an atomic integer that acts as a done flag. When you call Do, the implementation checks the flag first. If the flag is already set, the call returns instantly without touching the mutex. This makes subsequent calls extremely cheap.
If the flag is not set, the implementation locks the mutex. It checks the flag again inside the lock to handle the race condition where two goroutines passed the first check at the same time. Only one goroutine proceeds to run your function. After the function returns, the implementation sets the flag and unlocks the mutex. All waiting goroutines wake up, see the flag is now set, and return.
The design prioritizes read performance. The fast path skips locking entirely. The slow path uses a mutex to serialize the first execution. This pattern is common in systems programming, but Go hides the complexity behind a single method call.
Minimal example
Here is the simplest way to declare and use a package-level sync.Once.
package main
import (
"fmt"
"sync"
)
// package-level once ensures the setup runs exactly once
var setupOnce sync.Once
var config string
// loadConfig reads and parses the configuration file
func loadConfig() {
// simulate expensive I/O and parsing
config = "loaded-from-disk"
}
func main() {
// Do blocks until loadConfig finishes
setupOnce.Do(loadConfig)
// subsequent calls return immediately
setupOnce.Do(loadConfig)
fmt.Println(config)
}
The Do method takes a function with no arguments and no return values. It executes that function on the first call and ignores it on every call after that. The variable config is safe to read after Do returns because the memory write happens before the mutex unlocks, establishing a happens-before relationship.
Walking through the race
Imagine three goroutines call setupOnce.Do(loadConfig) at the exact same nanosecond. All three see the done flag as zero. All three attempt to acquire the mutex. The runtime scheduler picks one to win the lock. That goroutine runs loadConfig, sets the flag to one, and releases the lock.
The other two goroutines are still waiting on the mutex. They wake up, acquire the lock, check the flag, see it is now one, and return without running the function. The mutex ensures only one execution path touches the initialization logic. The flag ensures future calls skip the mutex entirely.
This behavior means Do is blocking. If your initialization function takes five seconds, every goroutine that calls Do during those five seconds will wait. That is usually what you want. You do not want half your service running with uninitialized state while the setup is still in progress.
Realistic pattern: lazy client initialization
In production code, you rarely initialize at the package level. You usually attach sync.Once to a struct so each instance manages its own lazy setup. This pattern appears frequently in database clients, HTTP pools, and cache wrappers.
package main
import (
"fmt"
"sync"
)
// Client holds a lazy-initialized connection
type Client struct {
once sync.Once
conn string
initErr error
}
// NewClient creates an uninitialized client
func NewClient() *Client {
return &Client{}
}
// GetConnection returns the shared connection or the initialization error
func (c *Client) GetConnection() (string, error) {
// capture error in a closure since Do does not return values
c.once.Do(func() {
c.conn, c.initErr = connectToDatabase()
})
return c.conn, c.initErr
}
// connectToDatabase simulates a slow network handshake
func connectToDatabase() (string, error) {
return "pg://localhost:5432", nil
}
func main() {
client := NewClient()
// multiple goroutines could call this safely
conn, err := client.GetConnection()
if err != nil {
fmt.Println("setup failed")
return
}
fmt.Println("using", conn)
}
The sync.Once type does not support returning values. It only accepts func(). To handle errors, you wrap the call in a closure that writes to struct fields. The closure captures c.initErr and c.conn by reference. After Do returns, those fields are fully populated. This pattern keeps the public API clean while respecting Go's convention of returning errors explicitly.
The panic trap and error handling
There is one behavior that trips up developers who assume Do is a simple guard. If the function passed to Do panics, sync.Once considers the execution incomplete. It does not set the done flag. The next call to Do will retry the function.
This design exists because a panic usually means the initialization failed catastrophically. Retrying gives you a chance to recover or observe the panic again. It also means you cannot use sync.Once for cleanup logic. Cleanup must run exactly once, even if it panics. Use a regular mutex and boolean flag for teardown.
If you pass a function that returns a value directly to Do, the compiler rejects the program with cannot use ... (value of type func() string) as func() value in argument. The signature is strict. You must wrap any initialization that produces a value or an error in a zero-argument closure.
Go does not provide a Reset method on sync.Once. The type is intentionally single-use. If your application needs to reinitialize state after a failure, create a new sync.Once instance or use a mutex with a manual done flag. The standard library favors explicit state management over hidden reset behavior.
When to reach for sync.Once
Use sync.Once when you need lazy initialization that runs exactly once across concurrent goroutines. Use a regular sync.Mutex with a boolean flag when you need to reset the initialization state after a failure. Use the init() function when you need package-level setup that runs before main() starts. Use sync.Map when you need to lazily initialize many different keys independently. Use plain sequential code when you do not need concurrency: the simplest thing that works is usually the right thing.
sync.Once is not a cache. It is a gate. Let it guard expensive setup, then move on.