The Dangers of Init
TL;DR
init()
is a very useful system for bootstrapping package-level information, but it comes at a cost.
All quotes are from the Go language spec.
https://go.dev/ref/spec#Package_initialization
Error Handling
One of Go's major strengths is explicit error handling. If a function can fail, idiomatically it should return an error.
The signature of init()
is similar to main()
, it accepts nothing and returns nothing.
As such, there is no clean way to handle errors in an init()
function.
Consider the following contrived example:
var DefaultConnection *Connection
type Connection struct { ... }
func (c *Connection) Open() error { ... }
func init() {
DefaultConnection = &Connection{...}
DefaultConnection.Open()
}
Unless the reader has seen that Open
can error, they would have no indication that the default connection could be dead (Go allows for functions that only return an error to omit the return variable if it is unused).
Using this connection could lead to runtime panics, undefined behavior and system instability.
The only way to understand init()
's behavior is to read the source code. It doesn't even show up under go doc
, which means bugs in init
will also be more likely to be missed by LLM enhanced editors like WindSurf which leverage go doc
extensively.
The only ways to communicate an error within an init()
is to panic
, or use another package-level variable. But when does this panic
trigger?
It triggers at runtime, specifically when the surrounding package is first imported and in an order based upon the filename the init()
is defined in, and the program cannot recover
from it.
Given the list of all packages, sorted by import path, in each step the first uninitialized package in the list for which all imported packages (if any) are already initialized is initialized. This step is repeated until all packages are initialized.
...
If a package has imports, the imported packages are initialized before initializing the package itself. If multiple packages import a package, the imported package will be initialized only once.
...
To ensure reproducible initialization behavior, build systems are encouraged to present multiple files belonging to the same package in lexical file name order to a compiler.
Performance costs
Due to init()
running when a package is first imported, is run in a single go-routine, and has no concept of context.Context
; init()
can cause startup slowdown.
If the operation in an init()
is costly, that cost is felt at program start, every single time.
For a real world example (not in Go) for why this matters: https://nee.lv/2021/02/28/How-I-cut-GTA-Online-loading-times-by-70/
Here is k9s
running the equivalent of mkdir -p
within its startup: https://github.com/derailed/k9s/blob/c2bbcd4c7220095ad441af456159da2813fc2114/cmd/root.go#L55-L58 (& creates a hiddden dependency upon the filesystem)
Avoiding the pitfalls
- Leverage
init()
sparingly- Certain systems (like
cobra
) favor patterns withinit()
, so it may make sense to adopt the ecosystem's patterns, but even projects like Zarf have since moved away frominit()
as a flag setting pattern.
- Certain systems (like
- Favor
const
for package-level defaults as much as possible. - If initializing a package-level default, use a pure
DefaultT() T
orDefaultT() (T, error)
function. example - If initializing a global state operation that you do not want to perform multiple times, and does not change within the package, leverage
sync.Once
and a private package variable. - Never
panic
in aninit()
, the program cannotrecover
from it.
Further
What other footguns of init()
have you seen?