The Singleton in Go. Safe, Boring, and Surprisingly Useful
How to build one-time, globally shared resources without shooting yourself in the foot
“Concurrency is simple. Safe concurrency is not.”
I used to think singletons were an anti-pattern—holdovers from languages that didn’t make dependency injection easy. But then I started writing more services in Go. Suddenly I needed one shared HTTP client, one database pool, one config loader, one metrics registry, one cryptographic key manager… and all of them needed to be initialized exactly once, safely, under concurrency.
That’s when I realized:
In Go, the singleton isn’t a design pattern you “shouldn’t” use. It’s a practical concurrency tool you’ll use all the time — if you build it safely.
This post walks through the right way to build singletons in Go, the wrong ways you’ve probably seen, and the concurrency internals that explain why sync.Once is the hero of all of it.
Why Singletons Matter in Go
In server code or background services, singletons commonly appear when you want:
one shared DB or Redis client
one memoized configuration object
one tokenizer, embedder, or model cache
one global logger
one expensive heavy-weight structure
What they all share:
They must only ever be initialized once and must be visible everywhere.
That is exactly what Go’s sync.Once is for.
The Best Pattern. sync.Once + Package-Level Variable
Here’s the canonical pattern — clean, safe, leak-free:
package config
import (
“sync”
)
var (
once sync.Once
cfg *Config
)
func Load() *Config {
once.Do(func() {
cfg = &Config{
Env: “prod”,
URL: “https://service”,
}
})
return cfg
}Usage:
c := config.Load()
fmt.Println(c.URL)Properties:
Only one initialization
No races
Reads are cheap after the first call
Goroutine-safe
No external locks required
This is the pattern that 99% of Go production singletons use.
A More Realistic Example: Lazy Database Initialization
package db
import (
“database/sql”
“sync”
)
var (
once sync.Once
client *sql.DB
initErr error
)
func Client() (*sql.DB, error) {
once.Do(func() {
db, err := sql.Open(”postgres”, “postgresql://...”)
if err != nil {
initErr = err
return
}
client = db
})
return client, initErr
}Now callers can do:
conn, err := db.Client()
if err != nil {
log.Fatal(err)
}Notice that the error is captured during initialization.sync.Once doesn’t rerun the function if it fails — so you must propagate the error.
How Not to Write a Singleton in Go
The naïve boolean-flag version
var initialized bool
func Get() *Thing {
if !initialized {
thing = createThing() // race!
initialized = true // race!
}
return thing
}Under concurrency, this can:
create the singleton more than once
leak memory
return a half-initialized value
race on the boolean and the object
This is 100% unsafe.
The mutex-only version
Some developers try this:
var mu sync.Mutex
var t *Thing
func Get() *Thing {
mu.Lock()
defer mu.Unlock()
if t == nil {
t = createThing()
}
return t
}This is safe, but worse than sync.Once because:
Every call to
Get()acquires a mutexThe first goroutine to initialize blocks others
The common path becomes slow
sync.Once avoids these issues by using:
a fast atomic read for the “done” check
a fallback slow-path only the first time
So the mutex version works, but it’s inefficient.
A Stronger Variant. Singleton With Parameters
Sometimes you want to initialize only once, but you also need arguments.
You cannot do:
func Init(x int) { /* ... */ }Because subsequent calls will be ignored.
Instead:
Safe pattern. Store args globally before calling Once
var (
once sync.Once
threshold int
engine *Engine
)
func Init(t int) {
threshold = t
once.Do(func() {
engine = &Engine{Threshold: threshold}
})
}
func Get() *Engine {
return engine
}You ensure:
Inputs are set before initialization
They’re used exactly once
Future calls cannot break the invariant
Resettable “Singletons” (Rarely a Good Idea)
sync.Once does not support resetting.
This is intentional.
If you truly need something that can be recreated—like a rotating configuration—you should use a mutex and explicit state management.
Example:
var (
mu sync.RWMutex
cfg *Config
)
func Load() *Config {
mu.RLock()
c := cfg
mu.RUnlock()
return c
}
func Reload() {
mu.Lock()
cfg = loadFromDisk()
mu.Unlock()
}This is not a singleton anymore — it’s mutable global state — but it’s the correct pattern for reloadable resources.
The Internal Model. Why sync.Once Is Perfect for Singletons
sync.Once uses two mechanisms internally:
1. Fast path
An atomic bit checks if the function has already run.
If yes → return immediately (no locks).
2. Slow path
On the first call:
Acquire a mutex
Run the initializer
Mark as done
Unblock waiting goroutines
This means:
Initialization happens only once
No races
No duplicate work
Future calls are nearly free
This is exactly what you want for singletons.
When You Should Not Use Singletons in Go
Avoid singletons if:
you want easy unit testing
your system needs different instances in different contexts
initialization depends on request-scoped data
you’re writing a library that shouldn’t depend on global state
Zero-dependency design is usually better.
But in infrastructure-level code (database pools, config parsing, metrics, etc.), singletons are often the cleanest solution.
Final Thoughts
Safe singletons in Go aren’t complicated — but they must be done right.
And the right way is almost always:
A package-level variable protected by
sync.Once, with the logic encapsulated behind a getter function.
It’s reliable, fast, idiomatic, and used in the standard library itself.

