skip to content

What is your policy on doing real work in init() in a Go library many teams import?

level: principalimportance: nice to knowfreq 28%

answer

  1. the consumer cannot decline it
  2. no parameters in, no error out
  3. every test binary pays, used or not
  4. who gets paged when it fails at 3am
  5. a constructor takes config, returns errors

basics

~20 s

Allow only cheap, deterministic, un-failable work at import time, because every consumer pays for it unconditionally and cannot configure, order, skip or error-check it. Anything that reads ambient state or can fail belongs in an exported constructor the caller invokes.

solid answer

~40 s

My rule is that import-time work is unconditional, unconfigurable and unreportable, so it must be something no consumer could object to. Precomputing a table, compiling a template, adding an entry to a registry the package itself owns -- fine. Reading environment variables, opening files, dialling dependencies, starting goroutines -- not fine: that runs in every binary that imports the package, including test binaries that never touch the feature, and it cannot return an error. The cost lands on consumers who cannot test around it, so the author does not get the last word. The alternative I require is an exported constructor taking configuration and returning an error, or lazy construction guarded by `sync.Once` where a package-level singleton is genuinely unavoidable. Enforcement is a review standard plus a CI analyzer, not good intentions.

code

go · 9 lines
go
// Acceptable: cheap, deterministic, cannot fail for environmental reasons.
var rowTmpl = template.Must(template.New("row").Parse("{{.Name}} {{.Value}}"))

// Not acceptable: reads ambient state, and has no way to report failure.
func init() {
	if os.Getenv("METRICS_URL") == "" {
		panic("metrics: METRICS_URL not set")
	}
}

go deeper

for a junior

Know the practical takeaway: a package that connects or reads configuration when it is merely imported is hard to use and hard to test, so prefer calling a constructor yourself.

for a middle

Be able to explain the mechanics behind the rule -- no parameters, no error return, order fixed by the import graph -- and name the alternatives: an exported constructor or lazy construction guarded by sync.Once.

for a senior

Argue the operational case with evidence: startup cost multiplied across every binary and test, failures that occur before the logger exists, and tests that become order- or environment-dependent.

for a principal

Own the policy and its enforcement: state the allowed list, decide who can overrule the package owner, choose the review and CI mechanism that makes it stick, and be willing to break your own API surface to remove import-time side effects.

### Why this is a policy question and not a style preference What a package does during initialization is one of very few decisions a library author makes that consumers cannot override. They cannot pass configuration into it -- init() takes no parameters. They cannot handle its failure -- init() returns nothing, so panicking is its only signal. They cannot skip it -- it runs because the package is linked in, not because anyone called it. They cannot sequence it relative to their own setup -- the order comes from the import graph. And they cannot easily test around it, because the same initialization runs in their test binaries. So the author is spending someone else's budget: startup latency, ambient dependencies, test determinism. That is what makes it an ownership call rather than taste, and why a consumer team blocked by it can reasonably overrule the author. ### The line I draw **Allowed at import time** -- work that is cheap, deterministic, dependency-free and cannot fail for environmental reasons: - precomputing a lookup table or a small map - compiling a template or a regular expression from a constant literal - adding an entry to a registry that this package owns, where the entry is pure data **Not allowed at import time:** - reading environment variables or configuration files - opening files, sockets, or database handles - starting goroutines, timers, or background loops - anything whose failure mode is a panic that the consumer cannot contextualise - anything that takes long enough to be noticeable, since every test binary pays it The test I apply in review: *if this failed, could the consumer do anything useful about it?* If the answer is no, it does not belong at import time. ### What replaces it An exported constructor is the default answer: `func New(cfg Config) (*Reporter, error)`. It takes configuration, returns a real error, is called at a moment the consumer chooses, and is trivially testable in both directions. In a metrics-reporting daemon, that means main() builds the reporter after flags and logging exist, and passes it to whatever needs it rather than leaving a package-level singleton for anyone to reach. When a genuine process-wide singleton is unavoidable -- typically because an existing API surface cannot change -- lazy construction on first use is the compromise: a `sync.Once` guarding the expensive work, so binaries that never use the feature never pay for it. Go 1.21 added `sync.OnceFunc`, `sync.OnceValue` and `sync.OnceValues`, which express the common cases more directly than a hand-written once-plus-variable pair. It is still second best: the failure still has no good place to go. ### The cost nobody counts Import-time work is charged per binary, not per use. A package that spends 150ms reading a data file at startup costs that to every command-line tool, every test binary and every service that links it, whether or not it calls a single function. In a large repository with hundreds of test packages, that is a measurable share of a CI run, and it is invisible in any profile anyone thinks to take, because it happens before the code under test starts. The second uncounted cost is test determinism. Once a package populates shared state during initialization, tests in other packages can start depending on it without anyone deciding to. The symptom shows up much later as a test that passes in the full suite and fails when its package is tested in isolation, and the person triaging that flaky CI build is rarely the person who wrote the init(). ### How I enforce it A written rule that nobody can see being broken is not a policy. Three things make it real: 1. **A review standard** stated in the repository's contribution guide, in one sentence, with the allowed list above. 2. **An analyzer** -- a small static check built on `golang.org/x/tools` -- that flags I/O and environment reads reachable from an init() function or a package-level variable initializer in shared packages, run in CI. 3. **An escalation path**: a consumer team whose tests are made non-deterministic by an import-time side effect files against the owning package, and the owning team is expected to move the work, not to explain the workaround. ### Where I would allow an exception Registration patterns where the alternative is materially worse for every consumer -- a plugin-style registry whose entries are pure data and whose failure is impossible -- are worth keeping, provided the registry is owned by the package doing the registering, and provided the entry costs microseconds. I would still require that no registration reads the environment or performs I/O, because those are the two properties that turn a harmless side effect into an untestable dependency. ### The judgment to voice "Import-time work is a cost I impose on every consumer and that none of them can decline, configure or handle. So the bar is: cheap, deterministic, cannot fail. Everything else gets an exported constructor, and I would rather break my own API than leave a consumer with tests they cannot make deterministic."

  • A team says moving work out of init() breaks their call sites. How do you weigh that?
    Against the number of consumers who cannot test around it. A one-time migration for the callers of one package is bounded and reviewable; permanently untestable startup behaviour is not. I would provide the constructor, migrate the known call sites myself, and treat the churn as the cost of the original decision, not as a reason to keep it.
  • When would you accept a lazy singleton over an explicit constructor?
    When the API surface genuinely cannot change -- an established package whose callers have no place to hold a value. Guarding the expensive work with sync.Once at least means binaries that never use the feature never pay for it. It is still second best, because the failure has no caller to return to.
  • How do you make the rule visible rather than aspirational?
    A one-sentence standard in the contribution guide, a CI analyzer built on golang.org/x/tools that flags I/O and environment reads reachable from init() or package-level initializers in shared packages, and an escalation path so a consumer whose tests turn non-deterministic files against the owning package.
  • What measurement would you bring to justify the policy?
    Startup cost per binary and the CI aggregate: import-time work is paid once per process, so a hundred-millisecond initializer is multiplied by every test binary that links the package. Pair that with the count of flaky-in-isolation test failures traced to shared startup state.

It is like a tenancy agreement clause that applies to every future tenant: because nobody who signs later can negotiate it, the bar for putting it in has to be much higher than for anything they can change themselves.

saying these in an interview costs you the question

  • Treats it purely as style rather than a cost imposed on consumers
  • Says init() is fine because the work has to happen anyway
  • Ignores that test binaries run the same initialization
  • Proposes a global flag to disable the side effect
  • Assumes the library author's convenience outranks consumer testability
  • Writes the rule down with no enforcement in review or CI