DEV Community

Cover image for sync.OnceFunc vs init(): Lazy Initialization Done Right in Go
Gabriel Anhaia
Gabriel Anhaia

Posted on

sync.OnceFunc vs init(): Lazy Initialization Done Right in Go


You've seen the binary that takes four seconds to print its
--help text. You run mytool --version, expecting an instant
answer, and instead the process hangs while something dials a
database, reads a config file, and warms a cache you never asked
for. None of that work was needed to print a version string. It
ran anyway, because somewhere in the import graph a package has
this:

func init() {
    db = mustConnect(os.Getenv("DATABASE_URL"))
    cache = warmCache(db)
}
Enter fullscreen mode Exit fullscreen mode

init() is the easy answer to "set this up once before anything
uses it." It is also the wrong answer most of the time. It runs at
import, before main gets a single line of control. It can't
return an error. And it makes the package almost impossible to test
in isolation. Go 1.21 added sync.OnceFunc and sync.OnceValue,
and between those two and the older sync.Once, you rarely need
init() for anything more than registering a driver.

What init() actually costs

The first cost is timing. Every init() in every imported package
runs before main, in dependency order, and you don't control
when. Import a package for one helper function and you've also
signed up for its init(). The CLI that takes four seconds to
print --version is paying for initialization it will never use on
that code path.

The second cost is error handling. An init function has the
signature func(). No return value. If the work inside can fail —
a missing env var, an unreachable host, a malformed config — your
only options are panic or swallow the error into a package
variable and hope someone checks it later.

var initErr error

func init() {
    db, initErr = sql.Open("postgres", dsn())
}
Enter fullscreen mode Exit fullscreen mode

Now every caller has to remember to check initErr before
touching db, and nobody does. A panic in init is worse: it
takes down the process before main can log anything useful or
fall back to a degraded mode.

The third cost is test isolation, and it's the one that hurts most.
init() runs when the test binary loads the package. You cannot
stop it, mock it, or run the test without it. If your init dials
a real database, your unit tests now need a real database to even
compile-and-load. The work happens before your first TestMain
line.

sync.OnceFunc: run it once, when you actually need it

sync.OnceFunc (Go 1.21+) takes a function and returns a wrapped
function that runs the original exactly once, no matter how many
goroutines call it or how often. The work is deferred until the
first call, not import time.

var setup = sync.OnceFunc(func() {
    registerMetrics()
    loadFeatureFlags()
})

func Handler(w http.ResponseWriter, r *http.Request) {
    setup()
    // ... handle the request
}
Enter fullscreen mode Exit fullscreen mode

The first request that hits Handler pays for setup. Every
request after that calls setup() and it returns immediately. If a
hundred requests arrive at once, exactly one runs the body and the
other ninety-nine block until it finishes, then proceed. That
blocking-until-done behavior is the part people forget sync.Once
gives you for free.

Compare that to the init version: the cost moved from import time
(always paid, even by --version) to first-use time (paid only on
the path that needs it). For a CLI with many subcommands, that's
the difference between a fast startup and a slow one.

sync.OnceValue: lazy init that returns something

Most initialization produces a value: a client, a parsed config, a
compiled template. sync.OnceValue is built for that. It takes a
function returning T and gives you back a function returning T,
computed once.

var config = sync.OnceValue(func() *Config {
    return loadConfig("/etc/app/config.yaml")
})

func someHandler() {
    cfg := config()
    // cfg is the same *Config on every call
}
Enter fullscreen mode Exit fullscreen mode

There's also sync.OnceValues for the common (T, error) shape,
which is where lazy init finally gets honest about failure.

Error handling: the thing init() can't do

Here is the pattern that replaces a fallible init. The connection
attempt runs once, on first use, and the error comes back to the
caller instead of vanishing into a package variable.

var getDB = sync.OnceValues(func() (*sql.DB, error) {
    db, err := sql.Open("postgres", dsn())
    if err != nil {
        return nil, err
    }
    if err := db.Ping(); err != nil {
        return nil, err
    }
    return db, nil
})

func FindUser(id string) (*User, error) {
    db, err := getDB()
    if err != nil {
        return nil, fmt.Errorf("db unavailable: %w", err)
    }
    return queryUser(db, id)
}
Enter fullscreen mode Exit fullscreen mode

getDB() runs the body once. The first caller that triggers it
gets the real error if the connection fails. Every subsequent
caller gets the same cached (*sql.DB, error) pair, including the
same error if it failed. That last part is worth a beat: if the
first call fails, the failure is cached too. OnceValues does not
retry. If you need retry-on-failure semantics, sync.Once and its
relatives are the wrong tool, and you want an explicit guard or a
connection pool that handles reconnection itself.

The win over init: the error reaches a place that can do
something about it. The caller can return it up the stack, log it,
or fall back. init had no caller to return to.

Test isolation: the part that sells it

This is the difference you feel every day. With init, the package
does its setup the moment the test binary loads it. With
OnceValue, nothing runs until your test calls the accessor — so a
test that doesn't need the database never touches it.

Make the accessor a package variable instead of calling
sync.OnceValue inline, and a test can swap it:

var getDB = sync.OnceValues(func() (*sql.DB, error) {
    return openProductionDB()
})
Enter fullscreen mode Exit fullscreen mode
func TestFindUser(t *testing.T) {
    fake := newFakeDB(t)
    getDB = func() (*sql.DB, error) {
        return fake, nil
    }
    t.Cleanup(func() {
        getDB = sync.OnceValues(func() (*sql.DB, error) {
            return openProductionDB()
        })
    })

    u, err := FindUser("123")
    // assert against the fake
}
Enter fullscreen mode Exit fullscreen mode

The test replaces getDB with a function that hands back a fake.
No real database, no network, no import-time surprise. You could
never do this with init, because by the time your test runs, the
init work already happened and there was no seam to intercept.

For tests that run in parallel and shouldn't share the variable,
keep the dependency on a struct field instead of a package
variable, and inject it through the constructor. The OnceValues
default lives in the constructor; the test passes its own. Same
laziness, no shared mutable state.

When init() is still the right call

init isn't banned. It earns its place when the work does no I/O
and can't fail, and the package genuinely can't be used until it
runs. The canonical example is driver registration:

func init() {
    sql.Register("postgres", &Driver{})
}
Enter fullscreen mode Exit fullscreen mode

That's pure, fast, can't fail in a way you'd recover from, and is
the documented contract for the database/sql driver model. Other
fair uses: building a lookup table from constants, compiling a
regex that's used everywhere and can't be invalid, wiring up a
package-level registry. The test is: does this do I/O, can it fail,
and does every code path that imports the package actually need it?
If any answer is "yes," reach for OnceValue instead.

Picking between the three

A short decision guide:

  • sync.Once — you have your own state to guard and want full control over what runs in the Do body. The oldest API, still fine.
  • sync.OnceFunc — side-effect-only setup, no return value. Registering metrics, warming a cache, starting a background loop once.
  • sync.OnceValue / sync.OnceValues — the setup produces a value (and maybe an error) you'll hand to callers. This is the one that replaces most fallible init functions.

The thread running through all of them: initialization is work, and
work that can fail or block belongs on a code path you control, not
in init where it runs before main and can't tell anyone it went
wrong.

What to change on Monday

Open your package and grep for func init. For each one, ask the
three questions: does it do I/O, can it fail, does every import need
it. The driver-registration and lookup-table cases stay. The ones
that dial a database, read a file, or call a remote service move to
a sync.OnceValue accessor. You'll get a faster startup, error
handling that reaches a caller, and tests that stop needing a live
database to load.

Lazy initialization is one of those Go topics that looks like a
one-line choice and turns out to shape how testable a whole package
is. The Complete Guide to Go Programming walks through the sync
package end to end — Once, the 1.21 additions, and the memory
model guarantees that make "run exactly once, visible to every
goroutine" actually hold. If you liked the reasoning here, that's
where the longer version lives.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)