Go sync.Once: One-Time Initialization

Go's `sync.Once` is a synchronization primitive that ensures a piece of code executes exactly once, regardless of how many goroutines attempt to run it. This is invaluable for initialization tasks...

Key Insights

  • sync.Once guarantees a function executes exactly once across all goroutines, solving initialization race conditions without complex locking logic
  • If the function passed to Do() panics, sync.Once considers it executed and will never retry, making panic handling critical
  • sync.Once outperforms mutex-based initialization checks by using atomic operations internally, with near-zero overhead after the first call

Introduction to sync.Once

Go’s sync.Once is a synchronization primitive that ensures a piece of code executes exactly once, regardless of how many goroutines attempt to run it. This is invaluable for initialization tasks that are expensive, must happen only once, or would cause problems if executed multiple times.

The typical use case involves lazy initialization: you want to defer expensive setup until it’s actually needed, but once initialized, you never want to repeat that work. Without sync.Once, implementing this correctly requires careful coordination with mutexes and double-checked locking patterns that are easy to get wrong.

sync.Once provides a simple, foolproof API: create a sync.Once variable and call its Do() method with your initialization function. The standard library handles all the complexity of ensuring thread-safety and happens-before guarantees.

The Problem: Race Conditions in Initialization

Let’s see why naive approaches fail. Consider a singleton pattern without proper synchronization:

package main

import (
    "fmt"
    "sync"
    "time"
)

type Database struct {
    ConnectionID int
}

var (
    instance *Database
    counter  int
)

func GetInstance() *Database {
    if instance == nil {
        counter++
        time.Sleep(10 * time.Millisecond) // Simulate expensive initialization
        instance = &Database{ConnectionID: counter}
        fmt.Printf("Created instance with ID: %d\n", counter)
    }
    return instance
}

func main() {
    var wg sync.WaitGroup
    
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            db := GetInstance()
            fmt.Printf("Goroutine %d got instance: %d\n", id, db.ConnectionID)
        }(i)
    }
    
    wg.Wait()
}

Running this code produces multiple “Created instance” messages. Multiple goroutines see instance == nil simultaneously and all proceed to create their own instance. This wastes resources and can cause serious bugs if your initialization has side effects like opening file handles or network connections.

How sync.Once Works

sync.Once provides a single method: Do(func()). The first goroutine to call Do() executes the provided function. All other goroutines block until that execution completes, then return immediately without executing the function again.

Here’s the basic usage:

package main

import (
    "fmt"
    "sync"
    "time"
)

var (
    instance *Database
    once     sync.Once
    counter  int
)

func GetInstance() *Database {
    once.Do(func() {
        counter++
        time.Sleep(10 * time.Millisecond)
        instance = &Database{ConnectionID: counter}
        fmt.Printf("Created instance with ID: %d\n", counter)
    })
    return instance
}

func main() {
    var wg sync.WaitGroup
    
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            db := GetInstance()
            fmt.Printf("Goroutine %d got instance: %d\n", id, db.ConnectionID)
        }(i)
    }
    
    wg.Wait()
}

Now you’ll see exactly one “Created instance” message, and all goroutines receive the same instance. The implementation uses atomic operations for fast-path checking and a mutex for the slow path, making it extremely efficient.

Common Use Cases

Lazy Singleton Initialization

The singleton pattern is the most common use case. Here’s a production-ready example:

package database

import (
    "database/sql"
    "sync"
    _ "github.com/lib/pq"
)

type Database struct {
    conn *sql.DB
}

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

func GetDB() (*Database, error) {
    once.Do(func() {
        conn, err := sql.Open("postgres", "postgresql://localhost/mydb")
        if err != nil {
            initErr = err
            return
        }
        
        if err := conn.Ping(); err != nil {
            initErr = err
            return
        }
        
        instance = &Database{conn: conn}
    })
    
    return instance, initErr
}

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

Notice how we capture initialization errors. Since Do() only runs once, we must store any error that occurs for subsequent callers to check.

One-Time Resource Setup

Configuration loading is another perfect use case:

package config

import (
    "encoding/json"
    "os"
    "sync"
)

type Config struct {
    DatabaseURL string `json:"database_url"`
    APIKey      string `json:"api_key"`
    Port        int    `json:"port"`
}

var (
    cfg  *Config
    once sync.Once
)

func Get() *Config {
    once.Do(func() {
        data, err := os.ReadFile("config.json")
        if err != nil {
            panic("failed to read config: " + err.Error())
        }
        
        cfg = &Config{}
        if err := json.Unmarshal(data, cfg); err != nil {
            panic("failed to parse config: " + err.Error())
        }
    })
    return cfg
}

Expensive Computation Caching

When you have a computationally expensive operation that produces a constant result:

package matcher

import (
    "regexp"
    "sync"
)

var (
    emailRegex *regexp.Regexp
    once       sync.Once
)

func ValidateEmail(email string) bool {
    once.Do(func() {
        // Complex regex compilation happens only once
        emailRegex = regexp.MustCompile(
            `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`,
        )
    })
    return emailRegex.MatchString(email)
}

Important Gotchas and Best Practices

Panic Behavior

If the function passed to Do() panics, sync.Once still considers it executed. It will never retry:

package main

import (
    "fmt"
    "sync"
)

var once sync.Once

func initialize() {
    once.Do(func() {
        fmt.Println("Initializing...")
        panic("initialization failed!")
    })
}

func main() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered from:", r)
        }
    }()
    
    initialize() // Panics
    
    // This will NOT retry initialization
    initialize() // Does nothing
    fmt.Println("Second call completed")
}

This behavior means you should handle errors gracefully rather than panicking, or ensure your panic is truly unrecoverable.

Don’t Copy sync.Once

sync.Once contains internal state and must not be copied after first use:

// WRONG - copying sync.Once
type Config struct {
    once sync.Once  // Will be copied when Config is copied
    data string
}

// CORRECT - use pointer
type Config struct {
    once *sync.Once
    data string
}

func NewConfig() *Config {
    return &Config{
        once: &sync.Once{},
    }
}

The go vet tool will catch most violations of this rule.

Blocking Behavior

All goroutines calling Do() block until the first execution completes. This means a slow initialization function blocks all callers:

var once sync.Once

func slowInit() {
    once.Do(func() {
        time.Sleep(5 * time.Second) // All callers wait 5 seconds
        fmt.Println("Done!")
    })
}

This is usually what you want, but be aware of it when debugging apparent deadlocks.

Performance Comparison

sync.Once is highly optimized. Here’s a benchmark comparing it to a mutex-based approach:

package main

import (
    "sync"
    "testing"
)

var (
    once    sync.Once
    mu      sync.RWMutex
    value   int
    init    bool
)

func BenchmarkSyncOnce(b *testing.B) {
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            once.Do(func() {
                value = 42
            })
            _ = value
        }
    })
}

func BenchmarkMutex(b *testing.B) {
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            mu.RLock()
            if !init {
                mu.RUnlock()
                mu.Lock()
                if !init {
                    value = 42
                    init = true
                }
                mu.Unlock()
            } else {
                mu.RUnlock()
            }
            _ = value
        }
    })
}

On most systems, sync.Once is 10-20x faster than the mutex approach after the first call, because it uses atomic operations that don’t require kernel involvement.

Conclusion

sync.Once is a specialized tool that solves one problem exceptionally well: ensuring initialization code runs exactly once in concurrent programs. Use it whenever you need lazy initialization of singletons, configuration, compiled regexes, or any expensive setup that should happen once.

The key takeaways:

  • Always use sync.Once for concurrent initialization instead of rolling your own double-checked locking
  • Remember that panics prevent retries—handle errors properly
  • Store initialization errors in package-level variables since Do() doesn’t return values
  • Never copy a sync.Once value after it’s been used
  • The performance characteristics make it essentially free after the first call

For most Go developers, sync.Once should be your default choice any time you write “if something == nil” in a concurrent context. It’s simple, correct, and fast—exactly what you want from a synchronization primitive.

Liked this? There's more.

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