The singleton problem in Go
You are building a service that needs a database connection pool. Every HTTP handler wants one. You do not want ten pools fighting for memory. You want one pool, shared everywhere. In Java, you would reach for a static block and a synchronized method. In Go, that pattern feels heavy. The language gives you something lighter that guarantees safety without the boilerplate.
Go does not have a "singleton pattern" because the language makes singletons trivial. You do not fight the compiler to get one. You get one by default if you ask for it right. There are two ways to hold a single instance. Package-level variables exist from the moment the program starts. They are thread-safe by specification. No locks needed. If you need to delay creation until someone actually asks, sync.Once steps in. It wraps a function and guarantees that function runs exactly once, even if a hundred goroutines call it at the same instant.
Go does not hide the cost. It makes the safe path the easy path.
Deferred initialization with sync.Once
Use sync.Once when initialization is expensive or depends on runtime configuration. The type ensures the wrapped function executes exactly once, regardless of how many goroutines call it. Subsequent calls return immediately without re-running the function.
Here is the standard pattern for a deferred singleton.
package main
import (
"fmt"
"sync"
"time"
)
// Config holds application settings loaded from a file.
type Config struct {
// DatabaseURL stores the connection string.
DatabaseURL string
}
var (
// instance holds the single Config value.
instance *Config
// once ensures the initialization function runs exactly once.
once sync.Once
)
// GetConfig returns the shared Config instance.
// It initializes the instance on the first call.
func GetConfig() *Config {
once.Do(func() {
// This block executes only once, regardless of concurrent calls.
// The runtime handles locking and memory barriers internally.
instance = &Config{
DatabaseURL: "postgres://localhost:5432/mydb",
}
})
return instance
}
func main() {
// Spawn multiple goroutines to prove thread safety.
for i := 0; i < 5; i++ {
go func(id int) {
c := GetConfig()
fmt.Printf("Goroutine %d: %s\n", id, c.DatabaseURL)
}(i)
}
// Wait for goroutines to finish.
// In production code, use sync.WaitGroup instead of time.Sleep.
time.Sleep(100 * time.Millisecond)
}
What happens under the hood
When main starts, instance is nil. once is empty. Goroutine 0 calls GetConfig. once.Do sees no prior execution. It acquires an internal lock, runs the function, sets instance, marks itself done, and releases the lock. Goroutine 1 calls GetConfig a microsecond later. once.Do sees the flag. It returns immediately without running the function. The lock never blocks the second goroutine.
The result is identical for everyone. The memory barrier inside sync.Once ensures that the write to instance is visible to all goroutines before the function returns. You do not need to worry about reordering. The compiler and runtime guarantee that once Do returns, every goroutine sees the initialized value.
Panic inside sync.Once kills the init forever. Handle errors before you lock.
Simple singletons with package variables
If initialization is cheap and static, sync.Once is overkill. Package-level variables are the Go way for simple singletons. The Go specification guarantees that package-level variables are initialized in a thread-safe manner before main starts executing. You get a singleton for free.
Here is the pattern for a static singleton.
package main
import "fmt"
// Logger represents a simple logging interface.
type Logger struct {
// Prefix is prepended to every log message.
Prefix string
}
// globalLogger is initialized once when the package loads.
// The Go runtime guarantees this happens before main() starts.
var globalLogger = &Logger{
Prefix: "[APP]",
}
// GetLogger returns the shared logger instance.
func GetLogger() *Logger {
return globalLogger
}
func main() {
l := GetLogger()
fmt.Printf("%s Starting up\n", l.Prefix)
}
This approach is faster at runtime because there is no locking overhead. The initialization happens during the program startup phase, which is single-threaded by default. Use this when the value can be constructed with constants or simple expressions.
Simple wins. Reach for the variable first. Reach for Once only when you must.
Handling errors during initialization
sync.Once does not return errors. If your initialization can fail, you need a separate variable to store the error. The community convention is to check the error on every call, or use a helper that panics on init failure. Storing the error ensures that if the first call fails, subsequent calls return the same error without retrying the expensive operation.
Here is the pattern for error-aware initialization.
package main
import (
"errors"
"fmt"
"sync"
)
var (
// instance holds the singleton value.
instance *Config
// initErr stores any error from initialization.
initErr error
// once guards the initialization.
once sync.Once
)
// GetConfig returns the shared Config and any initialization error.
func GetConfig() (*Config, error) {
once.Do(func() {
// Attempt initialization.
// If this panics, once considers the call done and future calls skip this block.
instance, initErr = initConfig()
})
return instance, initErr
}
// initConfig simulates loading configuration.
func initConfig() (*Config, error) {
// Simulate a failure condition.
return nil, errors.New("config file not found")
}
func main() {
cfg, err := GetConfig()
if err != nil {
fmt.Printf("Init failed: %v\n", err)
return
}
fmt.Printf("Config loaded: %v\n", cfg)
}
If initConfig panics, sync.Once treats the panic as a successful execution. Future calls to Do will not run the function. You get a nil instance and no error. This is a silent failure mode. Wrap initialization in a separate function and recover if you need to handle panics, or ensure the init logic cannot panic.
Pitfalls and anti-patterns
Avoid the double-checked locking pattern. You might see code that checks for nil, locks a mutex, checks for nil again, and initializes. This is fragile. Go's memory model allows reordering. Without sync.Once or sync/atomic, a goroutine might see a non-nil pointer to an uninitialized struct. The compiler will not stop you. The race detector will flag it with WARNING: DATA RACE when you run with -race. sync.Once handles the memory barriers and locking logic for you.
Global state couples code. If GetConfig is called from deep inside a function, you cannot swap the config for a test. The Go community prefers passing dependencies explicitly. A singleton is acceptable for truly global resources like a logger or a connection pool that cannot be easily mocked, but for business logic, pass the struct. Functions that accept interfaces and return structs are easier to test.
The worst singleton bug is the one that breaks your tests.
When to use which approach
Use a package-level variable when the singleton requires no external dependencies and can be constructed with constant values.
Use sync.Once when initialization is expensive or depends on runtime configuration that is not available at startup.
Use a constructor function that returns a struct when you want to allow multiple instances for testing or isolation.
Use sync.Mutex only when you need to mutate the singleton after creation, not for the creation itself.
Trust the standard library. sync.Once is optimized and correct. Do not reinvent it.