How to Implement the Singleton Pattern in Go (sync.Once)

Implement the Singleton pattern in Go using sync.Once to ensure thread-safe, one-time initialization of a global instance.

The problem with eager initialization

You are building a service that needs a database connection pool. Every HTTP handler wants to query data. You could create a new connection for every request, but that is slow and wastes resources. You could pass the connection through every function call, but that turns your signatures into a laundry list of dependencies. You need one shared resource, initialized lazily, safe for concurrent access, and guaranteed to exist after the first use.

Go does not have classes, so the classic Singleton pattern from Java or C++ does not translate directly. Instead, Go provides a primitive called sync.Once. It is a small struct that wraps a function and ensures that function runs exactly one time, no matter how many goroutines call it simultaneously. The first call executes the function. Every subsequent call returns immediately without running the function again. This is thread-safe by design.

sync.Once: the gatekeeper

Think of sync.Once like a fuse in an electrical circuit. The first surge of current blows the fuse and completes the circuit. After that, current flows freely without any resistance. The fuse does not reset. If you need to reset the circuit, you have to replace the fuse entirely. sync.Once works the same way: it triggers once, then becomes a no-op.

The Do method takes a function with no arguments and no return values. You pass a closure that captures the initialization logic. The first goroutine to call Do acquires a lock and runs the closure. All other goroutines waiting on Do block until the first one finishes. After the closure returns, sync.Once marks itself as done. Future calls to Do skip the lock and return instantly.

sync.Once is a fuse, not a switch. It burns once and stays open.

Minimal example

Here is the simplest way to create a shared instance using sync.Once. The pattern uses a package-level variable for the instance and a separate sync.Once variable to guard initialization.

package main

import (
	"fmt"
	"sync"
)

// DB represents a database connection.
type DB struct {
	// URL holds the connection string.
	URL string
}

// db holds the global instance.
var db *DB
// once ensures initialization happens only once.
var once sync.Once

// GetDB returns the shared database instance.
func GetDB() *DB {
	// Do runs the closure exactly once across all goroutines.
	once.Do(func() {
		// Initialize the connection on first access.
		db = &DB{URL: "postgres://localhost/mydb"}
	})
	return db
}

func main() {
	// First call triggers initialization.
	fmt.Println(GetDB().URL)
	// Second call returns the cached instance.
	fmt.Println(GetDB().URL)
}

The once.Do call guarantees the initialization logic executes only the first time GetDB is called. The closure captures the db variable and assigns the new struct. Subsequent calls return the same pointer without re-entering the closure.

How the fast path works

Under the hood, sync.Once maintains an atomic flag. When Do is called, it checks the flag using an atomic load operation. If the flag is set, Do returns immediately. This is the fast path. No locks are involved, and no synchronization overhead occurs. This makes sync.Once extremely efficient for high-concurrency workloads where the resource is accessed frequently after initialization.

If the flag is not set, sync.Once acquires a mutex. It checks the flag again to handle the race condition where another goroutine might have won the lock and already run the function. If the flag is still clear, it runs the closure, sets the flag, and releases the mutex. This double-check ensures the function runs exactly once, even under heavy contention.

The fast path is free. The slow path is safe.

Lazy loading in practice

In a real application, you often need to initialize complex resources like a logger, a metrics client, or a configuration loader. Lazy initialization defers the cost of creation until the value is actually needed. This improves startup time and saves memory for resources that might never be used.

Here is how sync.Once fits into a configuration loader. The struct and loader are defined together, keeping the state encapsulated at the package level.

// Config holds application settings.
type Config struct {
	Port  int
	Debug bool
}

var config *Config
var configOnce sync.Once

// LoadConfig returns the shared configuration.
func LoadConfig() *Config {
	// Do runs initialization exactly once.
	configOnce.Do(func() {
		// Perform expensive setup here.
		config = &Config{Port: 8080, Debug: true}
	})
	return config
}

Concurrent handlers can call LoadConfig safely. Only the first call triggers the closure. The rest get the cached value instantly.

func main() {
	// Concurrent goroutines all call LoadConfig.
	var wg sync.WaitGroup
	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			// Only the first goroutine triggers the closure.
			c := LoadConfig()
			fmt.Println(c.Port)
		}()
	}
	wg.Wait()
}

Lazy loading saves resources. Don't pay the cost until you need the value.

Pitfalls and panic traps

sync.Once has quirks that can bite you if you are not careful. The closure passed to Do must not panic. If the closure panics, sync.Once treats the call as a failure. It resets its internal state so the next call to Do will retry the closure. If your closure panics deterministically, you create an infinite loop of panics. The program crashes, but the retry logic means the panic propagates from the retry, not the original call. This can make debugging confusing.

Always handle errors inside the closure or before calling Do. If initialization can fail, sync.Once is the wrong tool. Use a mutex and a flag instead, so you can store the error and return it on subsequent calls.

Panics inside Do are infinite loops waiting to happen. Handle errors before the closure.

You cannot pass a function with arguments to Do. The signature is Do(f func()). If you try to pass a function that takes arguments, the compiler rejects it with cannot use func(string) {…} (value of type func(string)) as func() value in argument. You must wrap the call in a closure to capture the arguments. The closure has no arguments, satisfying the type requirement, while the inner call uses the captured values.

sync.Once does not support resetting. You cannot call Do again after the first success. If you need to reload configuration or reconnect a database, sync.Once is not the right choice. You would need to implement a reset mechanism using a mutex and a boolean flag, or use a different pattern like a worker pool that manages lifecycle explicitly.

Convention: initialization vs injection

The Go community generally prefers passing dependencies explicitly rather than relying on global singletons. sync.Once is acceptable for lazy initialization of shared resources, but you should prefer dependency injection for testability. If you inject the initialized value into your handlers or services, you can swap out the value in tests without resetting global state.

sync.Once helps you create the value once. Dependency injection helps you use it cleanly. When you see a package exposing a GetInstance function, ask whether the instance should be passed as a parameter instead. Global state makes code harder to reason about and harder to test. Use sync.Once to manage initialization, not to hide architecture.

Singletons hide dependencies. Use Once to initialize, not to obscure.

When to use sync.Once

Use sync.Once when you need to initialize a shared resource exactly once and serve it to multiple goroutines. Use a package-level variable initialized at startup when the resource is cheap to create and you do not need lazy loading. Use dependency injection when you want to keep your code testable and avoid global state. Use a mutex with a flag when you need to reset the initialization state or handle errors during setup. Use init() functions when you need to run setup code before main starts, though this does not support lazy loading.

Where to go next