The hidden queue
You split your package into three files to keep things tidy. One file reads environment variables. Another validates them against a schema. The third opens a database connection. You drop an init() function in each file, assuming they will run in the order you wrote them. The program starts. The database connection fails because the validation step never executed. You stare at the terminal, wondering why Go decided to run the files in a different sequence than you expected.
This is not a bug. This is how Go handles package initialization. The language guarantees that init() functions run before main(), but it deliberately leaves the cross-file order undefined. File names are labels, not a queue.
How Go actually orders init
Go treats a package as a single compilation unit, not a collection of ordered scripts. When the compiler processes a package, it gathers every init() function from every .go file in that directory. It does not sort them alphabetically. It does not follow import order. It chains them together based on internal file hashing and build parallelism. This design exists because Go prioritizes fast, deterministic compilation across large codebases. Requiring strict file-level ordering would force the build system to serialize file processing, slowing down compilation and complicating tooling.
Inside a single file, the rules are simple. Multiple init() functions run from top to bottom. The compiler preserves source order within that file. Across files, the compiler shuffles them during the build step. At runtime, the Go runtime executes the entire initialization chain in a single goroutine before handing control to main(). No concurrency exists yet. No HTTP servers are listening. No worker pools are running. The process is purely sequential setup.
File names are labels, not a queue.
The minimal proof
Here is the simplest way to observe the unpredictability. Create two files in the same package.
// a.go
package main
func init() {
// prints first in some builds, second in others
println("A initialized")
}
// b.go
package main
func init() {
// order relative to a.go is undefined by the language spec
println("B initialized")
}
// main.go
package main
func main() {
// runs only after all init functions complete
println("main started")
}
When you run this program, you will see either A initialized then B initialized, or the reverse. The compiler collects both init() functions, shuffles them during the build step, and links them into a single initialization chain. At runtime, the Go runtime executes that chain before calling main(). You cannot rely on alphabetical sorting or file creation time to dictate sequence. The language specification explicitly states that the order is implementation-defined and may change between compiler versions.
The compiler chains them. You cannot predict the shuffle.
Real package setup
Real projects need deterministic setup. A configuration package often reads flags, validates them, and initializes external clients. Splitting this across files without explicit ordering creates hidden dependencies. The fix is to consolidate the entry point. You keep your code organized across files, but you funnel the execution sequence through a single init() function that calls named setup functions in the exact order you want.
// config.go
package config
var dbURL string
func init() {
// explicit ordering guarantees env vars load before validation
loadEnvVars()
validateConfig()
connectDatabase()
}
func loadEnvVars() {
// reads from environment or falls back to defaults
// keeps the actual parsing logic isolated for testing
}
func validateConfig() {
// checks required fields and formats
// panics if configuration is invalid, crashing early
}
func connectDatabase() {
// opens the connection using the validated URL
// stores the client in a package-level variable
}
The compiler still sees only one init() in this file. It runs it. Inside, you explicitly call the setup functions in the exact order you want. The compiler cannot reorder function calls inside a function body. You trade a few lines of boilerplate for guaranteed execution sequence. The other files in the package can still hold helper functions, type definitions, or additional logic. They just do not define their own init() functions. If you need to split initialization across files for organizational reasons, you can still use multiple init() functions in one file, and they will run top to bottom. But cross-file ordering remains undefined.
Explicit calls beat implicit ordering every time.
Where init goes wrong
init() functions run in a single goroutine before any concurrency exists. If you panic inside init(), the entire program crashes before main() ever executes. You will see a stack trace that points directly to your initialization code, often with a message like panic: runtime error: index out of range or panic: assignment to entry in nil map. The compiler will not warn you about logical ordering mistakes. It only catches syntax and type errors. If you forget to call a setup function, the compiler happily builds the binary. The bug surfaces at runtime as missing state or failed connections.
Another trap is circular package initialization. If package A imports package B, and package B imports package A, the compiler rejects it with import cycle not allowed. Even without cycles, heavy init() chains can hide dependencies that make testing nearly impossible. Unit tests expect isolated, predictable state. When initialization happens automatically at package load time, you cannot easily reset state between tests. You end up writing integration tests instead of unit tests, or you resort to global flags to skip initialization during test runs.
The Go community generally discourages heavy init() usage. The convention is to keep init() short, or avoid it entirely in favor of explicit constructors. A function like NewConfig() that takes parameters and returns a fully initialized struct is easier to test, easier to read, and impossible to run in the wrong order. The init() function takes no parameters and returns nothing. It is a hook, not a design pattern. When you rely on it for complex setup, you are trading clarity for convenience.
A panic in init is a crash before the game starts.
When to reach for init
Initialization is setup, not magic. Choose the right tool based on your dependency graph and testing needs.
Use a single init() with explicit function calls when you need deterministic ordering within a package.
Use package-level variables initialized by explicit constructors when you want to test setup logic in isolation.
Use main() for complex orchestration when initialization depends on command-line flags or user input.
Avoid init() entirely when you can pass dependencies explicitly through function parameters and struct constructors.
Initialization is setup, not magic.