skip to content

What rule should your team's Go standard set for what init() may do before main?

level: principalimportance: should knowfreq 36%

answer

  1. start from what init structurally cannot do
  2. no parameters, no results, no caller
  3. the cost lands on every importer, including tests
  4. compute yes, wait or fail no
  5. the standard library's registry idiom is the exception

basics

~20 s

Set a narrow rule: init may only do work that is fast, cannot fail and cannot block, such as building tables or registering into the module's own registry. Anything with I/O or an error belongs in a constructor main calls.

solid answer

~50 s

I write the rule from what `init` structurally cannot do: it takes no arguments, returns nothing, runs before any logger or configuration exists, and runs in every binary that imports the package, tests included. So: package-level initialisers and `init` may compute, and may register into a registry the same module owns, provided the work is fast, total and non-blocking. Anything that can fail, wait, or depend on configuration moves into an exported constructor `main` calls with a `context.Context` and an `error` return. On blank-import self-registration I allow the narrow shape the standard library uses, and require that a binary's behaviour never change silently with which packages happen to be linked. I make it measurable too: `GODEBUG=inittrace=1` in CI against a startup budget, so a heavyweight new import is a visible regression rather than a debate.

code

go · 10 lines
go
// Acceptable at import time: fast, cannot fail, cannot block.
func init() {
	codecs["gzip"] = newGzipCodec
}

// Not acceptable: dials a service, has no way to report failure, and delays
// every binary that imports this package - including the test binary.
func init() {
	conn = mustDial(os.Getenv("CACHE_ADDR"))
}

go deeper

for a junior

Take away the practical guideline: keep init tiny and free of I/O, and put anything that can fail into a function main calls, so errors have somewhere to go.

for a middle

Be able to justify the guideline from mechanics - init has no parameters, no results and no caller, and it runs before configuration or logging exists in every binary that imports the package.

for a senior

Argue the line with real cases: which registrations you would keep, which import-time work you would move, and how you would find the offenders in an existing codebase.

for a principal

Own the whole call: the rule, the exception for module-owned registries, the honest cost in main-side wiring, the enforcement through a measured startup budget, and a migration posture that does not freeze delivery.

## Why this needs a rule at all `init` is the only place in Go where code runs without anyone calling it. That makes it powerful and makes it the one construct where a single author's convenience becomes everyone else's constraint - because the cost is paid by every binary that imports the package, transitively, forever. The structural facts the rule has to respect: - **No signature.** `init` takes nothing and returns nothing. It cannot receive configuration and cannot report failure; its only failure channel is panic, before any logger, metrics or health endpoint exists. - **No caller.** You cannot choose not to run it, run it twice, run it later, or run it differently in a test. - **Sequential and additive.** All initialisation runs on the main goroutine before `main.main`, so every package's cost adds directly to time-to-ready. - **Transitive.** An import three levels down decides what your binary does at startup. - **Runs in tests too.** A test binary initialises the same packages before any test does, so import-time state cannot be varied per test. ## The rule I would write **Package-level initialisation may compute; it may not wait, fail, or read the world.** Allowed at import time: - deriving data from constants or literals in the same package - lookup tables, precompiled patterns, sorted indexes; - registering an implementation into a registry defined by the same module; - assertions that are compile-time-ish in spirit and would be bugs, not environment problems, if they failed. Moved to a constructor `main` calls: - anything touching the network, the filesystem, the clock or the environment as *configuration*; - anything that can return an error; - anything whose result depends on a value the process learns at run time; - anything expensive enough to matter to startup latency. The constructor form is what buys back everything `init` gave away: a `context.Context` so it can be cancelled, an `error` so failure has a message and an exit code, parameters so tests can vary it, and a call site so it can be skipped, deferred or overlapped. ## The harder half: self-registration by blank import The pattern where importing a package for its side effects wires an implementation into a registry is not an abuse - it is the standard library's own idiom, used for image format decoders, for `database/sql` drivers, and for `net/http/pprof` attaching handlers to the default mux. Banning it outright would put a team at odds with the ecosystem and with reasonable plugin designs. So I draw the line at **observability of behaviour**, not at the mechanism: - Registration is acceptable when the import genuinely *is* the configuration - the binary declares which codecs or drivers it supports by importing them - and when the registry is explicit enough that a reader can find it. - It is unacceptable when linking a package changes behaviour a reader would not predict: a package that attaches routes to a shared default mux, overrides a global logger, or mutates another package's state at import time. That is action at a distance, and it makes a binary's behaviour a function of its dependency graph rather than of its `main`. - The reviewable test I give people: *if this import disappeared, would the binary fail loudly, or would it quietly do something different?* Quiet difference means it should be an explicit call. ## Costs of the rule, honestly stated Explicit wiring is more code in `main`, and adding a new implementation becomes a two-line diff in a place that is more contested than a leaf package. Some genuinely plugin-shaped systems become clumsier. A hard ban would also fight the standard library. That is why the rule allows the narrow registration case rather than pretending the pattern has no value - and why it comes with an exception process rather than an absolute. ## Making it stick A standard nobody can check is a preference. `go vet` does not police this, so I lean on three things: 1. **A measurement**: run one representative binary with `GODEBUG=inittrace=1` in CI and hold a startup budget, so a heavy new import shows up as a number. 2. **A review heuristic**: any new `init`, or any package-level variable whose initialiser calls a function, gets a sentence in the pull request explaining why it cannot be a constructor. 3. **A migration posture**: existing `init` functions are not rewritten en masse; they are converted when the package is next touched, prioritising ones that do I/O or that appear in the startup trace. The outcome I am buying is that a service's behaviour is determined by its `main`, its failures happen where something can report them, and time-to-ready is a number somebody owns.

  • Would you ban blank-import self-registration outright?
    No. It is the standard library's own idiom for pluggable registries - image decoders, `database/sql` drivers, `net/http/pprof` handlers - and a ban would push teams into worse workarounds. I restrict it to registries the module defines, and forbid import-time mutation of shared global state, where removing an import would silently change behaviour rather than fail loudly.
  • How do you enforce a rule like this without a linter that checks it?
    By making it measurable and reviewable. A startup budget checked with `GODEBUG=inittrace=1` in CI turns a heavy new import into a failing number rather than an opinion, and a review convention - any new `init` needs one sentence saying why it cannot be a constructor - catches the rest. If it recurs enough, it justifies writing a custom check.
  • A team says moving init work into constructors adds boilerplate to main. What is your answer?
    It does, and that is the trade being bought: the boilerplate is the dependency graph made visible. In exchange you get errors instead of panics, ordering you control, work you can skip or parallelise, and tests that can substitute a dependency. If a package's wiring is genuinely noisy, the fix is one constructor that assembles a subsystem, not moving work back to import time.
  • How would you migrate a large codebase that already relies heavily on init?
    Not in one sweep. I rank existing `init` functions by risk - anything doing I/O, anything with a panic path, anything expensive in the startup trace - and convert those first. The rest are converted when the package is next modified, and the standard applies strictly to new code, so the pile shrinks without a freeze.

saying these in an interview costs you the question

  • Bans init entirely, ignoring the standard library's own idiom
  • Allows any work in init as long as it is documented
  • Treats import-time I/O as fine because it usually succeeds
  • Ignores that init also runs in every test binary
  • Sets the rule with no way to measure or enforce it