Lazy initialization is useful when a value is expensive to build and may never be needed. The difficulty is making that initialization safe when several goroutines request the value at the same time.

Go has long provided sync.Once. Since Go 1.21, the sync package also includes OnceValue and OnceValues, helpers that return functions which compute results once and reuse them for later calls.

The manual sync.Once pattern

A classic implementation looks like this:

var (
    once   sync.Once
    client *http.Client
)

func getClient() *http.Client {
    once.Do(func() {
        client = &http.Client{
            Timeout: 5 * time.Second,
        }
    })
    return client
}

This works, but the state is split across variables. If initialization returns an error or multiple values, more shared variables are required.

Use OnceValue for one result

sync.OnceValue accepts a function and returns a new function:

package main

import (
    "fmt"
    "sync"
)

func main() {
    load := sync.OnceValue(func() string {
        fmt.Println("initializing")
        return "ready"
    })

    fmt.Println(load())
    fmt.Println(load())
}

The initializer runs once. Both calls receive the same result.

The returned function is safe to call concurrently, so callers do not need their own mutex.

Use OnceValues for a value and an error

Many initializers naturally return (T, error):

type Config struct {
    Region string
}

func readConfig() (Config, error) {
    return Config{Region: "us-east-1"}, nil
}

var loadConfig = sync.OnceValues(func() (Config, error) {
    return readConfig()
})

Callers can use it like a normal function:

cfg, err := loadConfig()
if err != nil {
    return err
}

The important behavior is that the pair of return values is cached. If the first call returns an error, later calls receive that same error. The initializer is not retried.

Decide whether failures should be sticky

Sticky failure is correct for some resources. If process configuration is invalid, repeatedly parsing the same invalid file may be pointless.

It is wrong for transient work that should be retried:

loadRemote := sync.OnceValues(func() ([]byte, error) {
    return fetchFromNetwork()
})

If the first request fails because of a temporary network problem, the failure remains cached for the lifetime of that function.

For retryable dependencies, use another design: explicit caching with expiration, a bounded retry policy, or eager startup initialization.

Keep request-scoped values out of global lazy state

A lazy initializer is often process-scoped. Do not capture the context of one HTTP request and reuse it forever.

Request-scoped context belongs to request-scoped work. Reserve lazy singletons for values whose lifetime actually matches the cache.

Panic behavior is remembered

If the initializer panics, the function returned by OnceValue or OnceValues will panic with the same value on future calls.

This matches the idea that initialization reached a terminal result. It also means these helpers should not wrap code that uses panic as a transient retry mechanism.

Example: lazily compile a reusable regular expression

Package-level regular expressions are often compiled eagerly:

var slugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)

That is perfectly reasonable for small programs. If initialization is optional or relatively expensive, lazy construction can be useful:

var slugPattern = sync.OnceValue(func() *regexp.Regexp {
    return regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
})

func validSlug(s string) bool {
    return slugPattern().MatchString(s)
}

The first caller pays the initialization cost; later calls reuse the compiled expression.

Do not use laziness without a reason

Lazy initialization has trade-offs:

  • first-use latency moves into a request or operation;
  • initialization failures happen later;
  • startup health checks may no longer detect missing dependencies;
  • hidden singleton state can make tests harder to isolate.

Eager startup is often better for essential configuration, database connectivity checks, and resources that every request needs.

Lazy construction is strongest when initialization is expensive, optional, immutable after creation, and safe to share.

Make lazy state testable

Package-level lazy functions are intentionally difficult to reset. That is useful in production but awkward in tests that need several initialization scenarios.

Prefer constructing the lazy function inside a component:

type Service struct {
    config func() (Config, error)
}

func NewService(loader func() (Config, error)) *Service {
    return &Service{
        config: sync.OnceValues(loader),
    }
}

Each test can create a fresh Service with a controlled loader.

Common pitfalls

Expecting automatic retry

An error from the first call is cached. Choose a retry-aware design when failures are transient.

Capturing mutable external state

The initializer runs only once, so later changes to captured variables do not cause recomputation.

Hiding essential startup failures

If the application cannot function without a resource, initializing it at startup can provide faster and clearer failure.

Using lazy globals as a dependency-injection shortcut

A singleton may reduce parameters, but it also hides dependencies. Keep ownership explicit when lifecycle management or test isolation matters.

Choosing between the helpers

Use sync.Once when initialization is fundamentally a side effect. Use sync.OnceValue for one computed result. Use sync.OnceValues when the initializer returns two values, commonly a value and an error.

The helpers reduce boilerplate, but the architectural question remains the same: should the first result live for the lifetime of the lazy function? If yes, they provide a compact and concurrency-safe implementation.