sync.Once & Singleton Patterns
sync.Once runs a function exactly one time, even when many goroutines call it concurrently.
Teams use it for lazy initialization of expensive resources while avoiding init() ordering hazards.
Summary
A singleton exposes one shared instance for the whole program.
Go can implement that with package-level variables, but idiomatic production code prefers explicit dependency injection and reserves sync.Once for cases like registering codecs or loading TLS roots where a single lazy setup is unavoidable.
The stdlib uses sync.Once internally for one-time initialization; your application code should use it sparingly and document why globals are justified.
Recipe
Quick-reference recipe card - copy-paste ready.
var (
parser *regexp.Regexp
once sync.Once
)
func emailPattern() *regexp.Regexp {
once.Do(func() {
parser = regexp.MustCompile(`^[^@]+@[^@]+\.[^@]+$`)
})
return parser
}When to reach for this:
- One-time expensive compile/load (regex, schema, default HTTP transport)
- Lazy init when
maincannot easily pass dependencies into deep call stacks - Library-internal caches that must be process-wide
Working Example
package metrics
import (
"sync"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
type Registry struct {
requests *prometheus.CounterVec
}
var (
defaultReg *Registry
once sync.Once
)
func Default() *Registry {
once.Do(func() {
defaultReg = &Registry{
requests: promauto.NewCounterVec(
prometheus.CounterOpts{Name: "http_requests_total", Help: "HTTP requests"},
[]string{"route", "code"},
),
}
})
return defaultReg
}
func (r *Registry) Inc(route, code string) {
r.requests.WithLabelValues(route, code).Inc()
}
func ExampleUsage() {
Default().Inc("/health", "200")
}What this demonstrates:
once.DoinitializesdefaultRegon firstDefault()call- Subsequent calls return the same pointer without locking on the hot path
- Exported
Default()documents the global access point
Deep Dive
How It Works
sync.Oncestores a done flag and mutex- First
Dorunsf; concurrent waiters block until completion - After completion,
Dobecomes a fast atomic load - If
fpanics,Onceis stuck - do not panic insideDo
sync.Once versus init()
| Mechanism | Runs when | Risk |
|---|---|---|
init() | Package load, fixed order | Import side effects, untestable failures |
sync.Once | First explicit call | Hidden global if overused |
Constructor in main | Startup, explicit | Best testability |
Singleton Trade-offs
| Approach | Testability | Simplicity |
|---|---|---|
Parameters from main | High | Requires wiring |
sync.Once package global | Low | Convenient |
init() global | Lowest | Easiest to write |
Go Notes
// Prefer this in application code:
func NewAPI(reg *metrics.Registry) *API { return &API{reg: reg} }
// In main:
reg := metrics.NewRegistry()
api := NewAPI(reg)- Resetting globals in tests requires build tags or dependency injection
- Do not call
once.Dowith different functions - only the first runs - For process-wide
http.DefaultClient, prefer explicit client with timeout
Gotchas
- Panics inside once.Do -
Oncenever retries; package stays broken. Fix: validate beforeDoor panic only on programmer errors you accept as fatal. - Singleton database pool - Tests race on shared state; migrations become global. Fix: pass
*sql.DBfrommainor test helper. - init() dials services - Importing package in tests hits production. Fix: lazy
sync.Onceor no init networking. - Hidden Default() everywhere - Signatures omit dependencies; fakes impossible. Fix: inject interface; keep
Default()for demos only. - Double-checked locking by hand - Easy to get wrong before Go 1.21 memory model assumptions. Fix: use
sync.Onceinstead of custom atomics. - Once per instance confusion -
Oncemust live on the struct if each instance initializes separately. Fix: package-levelOnceonly for true singletons.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Dependency injection | Application services, APIs | Internal regex cache |
init() for pure constants | Registering drivers, math tables | Network or env configuration |
sync.Once lazy init | Costly one-time setup | Every struct field |
Factory in main | Multiple environments (prod/stage) | Library hiding resources |
FAQs
Is singleton an anti-pattern in Go?
Package-level mutable singletons are discouraged in application code because they harm tests and hide dependencies.
sync.Once for immutable lazy setup is acceptable in libraries.
Can sync.Once reset?
No - create a new Once value on a new struct if you need per-instance one-time setup.
Is sync.Once slow?
After the first call, Do is a single atomic load - negligible on hot paths.
What about singleflight for requests?
golang.org/x/sync/singleflight deduplicates concurrent work by key - related but not the same as singleton instances.
Should libraries expose Default()?
Only when the ecosystem expects it (http.DefaultClient pattern).
Prefer New returning configured instances.
How do I test code using sync.Once?
Refactor to accept interfaces, or test through the public API accepting shared global state as integration tests.
init() ordering pitfalls?
Init order follows import graph; cyclic imports fail.
Keep init() free of I/O.
Lazy init versus eager init in main?
Eager init in main fails fast at startup; lazy init defers cost until first use and may hide misconfiguration.
Does Go need sync.Once with generics?
Yes - sync.Once remains the standard for one-time setup regardless of generics.
How does this relate to controller-runtime managers?
Operators construct one manager in main and pass clients explicitly - preferred over singleton clients.
Related
- Constructor Functions & Package APIs - explicit New wiring
- Go Patterns Basics - sync.Once introductory example
- Common Anti-Patterns in Go Codebases - init() and global state
- Go Idioms: Composition Over Inheritance - dependency injection bias
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify at build).