Singleton Pattern in Go: sync.Once Implementation

The singleton pattern ensures a struct has only one instance throughout your application's lifetime while providing a global access point to that instance. It's one of the simplest design patterns,...

Key Insights

  • Go’s sync.Once provides a thread-safe, idiomatic way to implement lazy singletons without the complexity of manual mutex management
  • Naive singleton implementations using simple nil checks create race conditions that can cause subtle, hard-to-debug issues in concurrent Go programs
  • While singletons have their place, prefer dependency injection for most use cases—reserve singletons for truly global, stateless services like configuration or logging

Introduction to the Singleton Pattern

The singleton pattern ensures a struct has only one instance throughout your application’s lifetime while providing a global access point to that instance. It’s one of the simplest design patterns, yet implementing it correctly in a concurrent language like Go requires careful consideration.

Common use cases include configuration managers that load settings once at startup, database connection pools that maintain a fixed set of connections, and loggers that need consistent behavior across your entire application. These scenarios share a common thread: you need exactly one instance, and creating multiple instances would either waste resources or cause incorrect behavior.

Go’s concurrency model makes naive singleton implementations dangerous. Unlike single-threaded languages where a simple nil check suffices, Go programs routinely spawn thousands of goroutines that can access shared state simultaneously. This is where sync.Once becomes essential.

The Problem with Naive Go Implementations

Let’s start with what not to do. Here’s a singleton implementation that looks reasonable but fails spectacularly under concurrent access:

package config

type Config struct {
    DatabaseURL string
    APIKey      string
    Debug       bool
}

var instance *Config

func GetInstance() *Config {
    if instance == nil {
        // Simulate expensive initialization
        instance = &Config{
            DatabaseURL: loadFromEnv("DATABASE_URL"),
            APIKey:      loadFromEnv("API_KEY"),
            Debug:       loadFromEnv("DEBUG") == "true",
        }
    }
    return instance
}

This code has a textbook race condition. When multiple goroutines call GetInstance() simultaneously, here’s what can happen:

  1. Goroutine A checks instance == nil (true) and enters the if block
  2. Before A finishes initialization, Goroutine B checks instance == nil (still true)
  3. Both goroutines create separate instances
  4. One instance gets overwritten, potentially after another goroutine already started using it

The consequences range from wasted resources to data corruption. If your initialization has side effects—like registering with an external service or opening file handles—you’ll see duplicate registrations or resource leaks.

Running go build -race and testing this code under load will immediately flag the race condition. The Go race detector is your friend here, but prevention is better than detection.

sync.Once: Go’s Idiomatic Solution

Go’s standard library provides sync.Once specifically for this scenario. It guarantees that a function executes exactly once, regardless of how many goroutines call it concurrently.

package config

import "sync"

type Config struct {
    DatabaseURL string
    APIKey      string
    Debug       bool
}

var (
    instance *Config
    once     sync.Once
)

func GetInstance() *Config {
    once.Do(func() {
        instance = &Config{
            DatabaseURL: loadFromEnv("DATABASE_URL"),
            APIKey:      loadFromEnv("API_KEY"),
            Debug:       loadFromEnv("DEBUG") == "true",
        }
    })
    return instance
}

Under the hood, sync.Once uses an atomic flag combined with a mutex. The first call acquires the lock, executes the function, and sets the flag. Subsequent calls see the flag is set and return immediately without acquiring the lock. This makes the fast path (after initialization) extremely cheap.

Why prefer sync.Once over alternatives?

vs. init() functions: Package init() runs at program startup, before main(). This works for simple cases but forces initialization even if the singleton is never used. It also makes testing harder since you can’t control when initialization happens.

vs. manual mutex: You could wrap the nil check in a sync.Mutex, but you’d pay the mutex overhead on every access. sync.Once only synchronizes during the first call.

vs. atomic operations: You could implement double-checked locking with atomic.Value, but it’s error-prone and sync.Once already does this correctly.

Complete Implementation Pattern

For production code, you’ll want better encapsulation and possibly initialization parameters. Here’s a more robust pattern:

package database

import (
    "database/sql"
    "sync"
)

type DB struct {
    conn     *sql.DB
    maxConns int
    timeout  time.Duration
}

type Option func(*DB)

func WithMaxConnections(n int) Option {
    return func(db *DB) {
        db.maxConns = n
    }
}

func WithTimeout(d time.Duration) Option {
    return func(db *DB) {
        db.timeout = d
    }
}

var (
    instance *DB
    once     sync.Once
    initErr  error
)

// Initialize must be called before GetInstance.
// Options are only applied on first call.
func Initialize(dsn string, opts ...Option) error {
    once.Do(func() {
        db := &DB{
            maxConns: 10,           // defaults
            timeout:  30 * time.Second,
        }
        
        for _, opt := range opts {
            opt(db)
        }
        
        conn, err := sql.Open("postgres", dsn)
        if err != nil {
            initErr = err
            return
        }
        
        conn.SetMaxOpenConns(db.maxConns)
        db.conn = conn
        instance = db
    })
    return initErr
}

func GetInstance() *DB {
    if instance == nil {
        panic("database: Initialize must be called before GetInstance")
    }
    return instance
}

func (db *DB) Query(query string, args ...any) (*sql.Rows, error) {
    return db.conn.Query(query, args...)
}

This pattern provides several improvements:

  1. Functional options allow flexible configuration without breaking the singleton guarantee
  2. Error handling captures initialization errors and returns them to the caller
  3. Explicit initialization makes the dependency clear rather than hiding it in a getter
  4. Panic on misuse catches programming errors early rather than returning nil

The trade-off is that callers must ensure Initialize is called before GetInstance. In practice, this happens in your main() function or a setup phase, making the requirement easy to satisfy.

Testing Singletons

Singletons and testing don’t mix well. The global state persists between tests, causing flaky results and order-dependent failures. Here are two strategies to manage this.

Strategy 1: Reset mechanism for tests

package database

import "sync"

var (
    instance *DB
    once     sync.Once
    initErr  error
)

// Reset clears the singleton for testing.
// This is not thread-safe and should only be used in tests.
func Reset() {
    instance = nil
    once = sync.Once{} // zero value is ready to use
    initErr = nil
}
package database_test

import (
    "testing"
    "myapp/database"
)

func TestDatabaseInitialization(t *testing.T) {
    t.Cleanup(database.Reset)
    
    err := database.Initialize("postgres://test:test@localhost/test")
    if err != nil {
        t.Fatalf("initialization failed: %v", err)
    }
    
    db := database.GetInstance()
    // ... test database operations
}

Strategy 2: Interface-based design

A cleaner approach extracts an interface and uses dependency injection in your application code:

package database

type Querier interface {
    Query(query string, args ...any) (*sql.Rows, error)
    Exec(query string, args ...any) (sql.Result, error)
}

// DB implements Querier
type DB struct { /* ... */ }

// In your service code:
type UserService struct {
    db Querier  // interface, not concrete type
}

func NewUserService(db Querier) *UserService {
    return &UserService{db: db}
}

Now tests can inject a mock without touching the singleton at all. The singleton still exists for production use, but your business logic doesn’t depend on it directly.

Alternatives and Trade-offs

Before reaching for a singleton, consider whether you actually need one.

Package-level init() works well for truly static configuration that never changes and has no dependencies:

package version

var (
    Version   string
    BuildTime string
)

func init() {
    Version = getVersionFromBuildFlags()
    BuildTime = getBuildTime()
}

Dependency injection is almost always preferable for services with behavior. Pass dependencies explicitly through constructors:

func main() {
    cfg := config.Load()
    db := database.Connect(cfg.DatabaseURL)
    logger := logging.New(cfg.LogLevel)
    
    userService := users.NewService(db, logger)
    server := http.NewServer(userService)
    server.Run()
}

This approach makes dependencies explicit, simplifies testing, and avoids the global state that makes singletons problematic.

When singletons are appropriate:

  • Loggers where you genuinely want one consistent output stream
  • Configuration that’s loaded once and never changes
  • Connection pools managed by a library (like database/sql)
  • Metrics collectors that aggregate across the application

When to avoid singletons:

  • Services with business logic
  • Anything you need to mock in tests
  • State that varies between requests or contexts

Summary

Implementing singletons correctly in Go requires sync.Once. The naive nil-check approach creates race conditions that will bite you in production. sync.Once provides exactly the guarantees you need: thread-safe, exactly-once initialization with minimal overhead after the first call.

That said, question whether you need a singleton at all. Dependency injection produces more testable, maintainable code. Reserve singletons for genuinely global, stateless services where a single instance is a hard requirement, not just a convenience.

When you do use singletons, keep them minimal. A singleton that just holds configuration is fine. A singleton that contains business logic is a code smell. And always provide a reset mechanism for tests—your future self will thank you.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.