What rule should your team's Go standard set for what init() may do before main?
answer
- start from what init structurally cannot do
- no parameters, no results, no caller
- the cost lands on every importer, including tests
- compute yes, wait or fail no
- the standard library's registry idiom is the exception
basics
~20 sSet 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 sI 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// 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
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.
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.
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.
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