The first thing that runs
You write a command-line tool that needs to load configuration from a file before it does anything else. You put the loading logic in main(), but the program crashes because a downstream package tries to read the config before it exists. You move the logic to an init() function, and suddenly everything works. But why? Go does not guess what runs first. It follows a strict, deterministic sequence that guarantees every dependency is fully prepared before your code touches it.
How Go boots up
Go programs start with a predictable three-phase boot sequence. The compiler resolves imports recursively, runs initialization functions, and finally hands control to your entry point. Think of it like preparing a kitchen before service. You unpack and organize the ingredients first. You season and prep the components next. Only then do you turn on the stove and start cooking. If you try to cook before the ingredients are prepped, the dish fails. Go enforces the prep order at compile time so you never accidentally cook with raw dependencies.
The sequence follows three hard rules. First, the compiler walks the import graph depth-first. If package A imports B and C, B initializes completely before C even begins. Second, within each package, every init() function runs sequentially. The compiler orders them by the position they appear in the source file, and it sorts multiple files alphabetically by filename. Third, main() runs only after every imported package and the main package itself have finished their initialization. This guarantee eliminates race conditions during startup and makes debugging predictable.
A minimal example
The following code demonstrates the exact order Go follows when booting a program with a single dependency.
// main.go
package main
import (
"fmt"
"myapp/config" // Import the configuration package
)
// init runs before main, after all imports finish
func init() {
fmt.Println("Main package init")
}
// main is the program entry point
func main() {
fmt.Println("Main function start")
config.PrintValue()
}
// config/config.go
package config
import "fmt"
// init prepares the configuration package
func init() {
fmt.Println("Config package init")
}
// PrintValue outputs the loaded setting
func PrintValue() {
fmt.Println("Config function called")
}
Run this program and the terminal prints exactly this sequence:
Config package init
Main package init
Main function start
Config function called
The config package initializes first because main imports it. The main package initializes second because it is the root of the import tree. The main() function runs last. If you add a second init() function to main.go, it runs immediately after the first one, top to bottom. If you split the main package into a_setup.go and b_setup.go, the compiler processes a_setup.go first because alphabetical ordering determines file execution order.
Goroutines are cheap. Initialization order is deterministic. Trust the sequence.
What happens under the hood
The Go compiler does not leave initialization to chance. During the compilation phase, it generates a hidden initialization function for every package. This synthetic function calls the initialization routines of all imported packages in depth-first order, then calls every init() function declared in that package. The linker stitches these synthetic functions together into a single startup chain. When the operating system loads the binary, the runtime executes this chain before jumping to main().
This design choice eliminates the unpredictable static initialization order that plagues languages like C++ or Java. In those languages, global constructors can run in arbitrary order depending on the linker or classloader. Go removes that ambiguity by baking the order into the import graph. You can trace the exact startup sequence by reading the import statements. You never need to guess which package runs first.
The compiler also enforces strict boundaries around init() functions. They cannot accept parameters. They cannot return values. They cannot be called manually. The language treats them as reserved hooks for side effects only. This restriction prevents developers from building complex initialization pipelines that hide control flow. If you need to pass configuration or handle errors during setup, you write a regular function and call it explicitly.
Convention aside: the Go community treats init() as a registration hook, not a business logic container. You will see it used to register HTTP handlers, database drivers, or template functions. You will rarely see it used to parse files or connect to networks. Keep the hook light. Move heavy work to explicit setup functions.
Real-world initialization
Real applications rarely consist of a single import. They chain multiple packages that depend on each other. The import order directly controls the initialization order. Consider a logger package that needs a configuration package, and a main package that needs both.
// config/config.go
package config
import "fmt"
// init loads default settings
func init() {
fmt.Println("Config: loading defaults")
}
// GetLogLevel returns the current log level
func GetLogLevel() string {
return "info"
}
// logger/logger.go
package logger
import (
"fmt"
"myapp/config" // Logger depends on config
)
// init configures the logger after config is ready
func init() {
level := config.GetLogLevel()
fmt.Printf("Logger: initialized with level %s\n", level)
}
// Log prints a message to stdout
func Log(msg string) {
fmt.Println("[LOG]", msg)
}
// main.go
package main
import (
"fmt"
"myapp/logger" // Main depends on logger
)
// init prepares the main package
func init() {
fmt.Println("Main: setup complete")
}
// main starts the application
func main() {
fmt.Println("Main: starting")
logger.Log("Application running")
}
The output follows the dependency chain exactly:
Config: loading defaults
Logger: initialized with level info
Main: setup complete
Main: starting
[LOG] Application running
The config package runs first because logger imports it. The logger package runs second because main imports it. The main package runs third. You can verify this chain by adding print statements to each init() function. This is the standard debugging technique for tracing startup flow. You do not need external profilers or debuggers to understand the order. The import graph tells you everything.
Do not fight the import graph. Structure your dependencies so the initialization order matches your architectural intent.
Pitfalls and compiler boundaries
Initialization order is powerful, but it introduces specific failure modes. The most common is circular imports. If package A imports package B, and package B imports package A, the compiler rejects the program with import cycle not allowed. The initialization sequence becomes undefined, so Go refuses to compile it. You resolve this by extracting the shared dependency into a third package that both A and B import.
Another trap is relying on initialization order between unrelated packages. If package A and package B are both imported by main, but A does not import B, you cannot assume A runs before B. The compiler orders them alphabetically by import path, which changes if you rename a package or move it to a different directory. Hardcoding assumptions about cross-package init order creates brittle code that breaks during refactoring.
Heavy logic in init() functions also causes subtle bugs. Because init() cannot return errors, you cannot propagate failures to the caller. If a file read fails during initialization, you must panic or silently degrade. Both approaches make debugging difficult. The community convention is to keep init() functions under ten lines of code. Use them for registration, flag parsing, or simple state setup. Move network calls, database migrations, and file parsing to explicit constructor functions that return errors.
Forget to import a package and you get undefined: pkg from the compiler. Forget to use an imported package and you get imported and not used. The compiler catches most initialization mistakes before runtime. Trust the type checker. Write explicit setup functions when you need error handling.
When to use init versus other patterns
Initialization strategies depend on your control requirements and error handling needs. Choose the pattern that matches your boot sequence complexity.
Use init() when you need automatic registration of handlers or drivers before any user code runs. Use a constructor function when you want explicit control over initialization order and error handling. Use main() when you are ready to execute the primary application logic after all dependencies are ready. Use a dedicated setup package when your boot sequence grows too complex for scattered initialization functions.
The worst initialization bug is the one that panics silently. Make setup failures visible.