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 with init(), so it may make sense to adopt the ecosystem's patterns, but even projects like Zarf have since moved away from init() as a flag setting pattern.
  • Favor const for package-level defaults as much as possible.
  • If initializing a package-level default, use a pure DefaultT() T or DefaultT() (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 an init(), the program cannot recover from it.

Further

What other footguns of init() have you seen?