Avoiding Common Pitfalls with Go's init Function
Understanding the init Function
In Go, the init function is a special mechanism used to initialize a package. It is a parameter-less function with no return values. When a package is initialized, the Go runtime processes constant and variable declarations first, executes the init function, and finally calls the main function.
The following code demonstrates the execution order:
package main
import "fmt"
var config = func() string {
fmt.Println("Variable initialization")
return "default"
}()
func init() {
fmt.Println("Package initialization")
}
func main() {
fmt.Println("Main execution")
}
Output:
Variable initialization
Package initialization
Main execution
Execution Order and Package Dependencies
Initialization follows the dependency graph. If package main imports package redis, the redis package's variables and init functions execute before those in main. A single package can contain multiple init functions across multiple files. Within a single file, they execute in declaration order. However, across files within the same package, the order is determined by the compiler's file processing order (usually alphabetical), which should not be relied upon for correctness.
It is also possible to define multiple init functions within the same source file:
package main
import "fmt"
func init() {
fmt.Println("First initializer")
}
func init() {
fmt.Println("Second initializer")
}
func main() {}
Output:
First initializer
Second initializer
Another characteristic is that init functions cannot be invoked manually. Attempting to call init() from elsewhere in the code will result in a compilation error: undefined: init.
Pitfalls of Using init for Resource Setup
A common anti-pattern involves managing resources like database connections inside init. Consider the following example where a database connection is established globally:
var database *sql.DB
func init() {
dsn := os.Getenv("DB_CONNECTION_STRING")
var err error
database, err = sql.Open("postgres", dsn)
if err != nil {
log.Panic(err)
}
if err = database.Ping(); err != nil {
log.Panic(err)
}
}
This approach introduces several issues:
- Error Handling Limitations: Since
inithas no return value, errors must be handled internally, often leading to a panic. This forces the application to terminate without giving the caller a chance to implement retry logic or graceful degradation. - Testing Complexity: The
initfunction runs before any test cases. If the tests do not require a database, theinitfunction still attempts to connect, making unit tests brittle and dependent on external infrastructure. - Global State: Assigning the connection to a global variable creates hidden dependencies and makes the code harder to test due to the lack of encapsulation.
Preferred Approach: Explicit Initialization
Rather than relying on side effects in init, a better practice is to define an explicit constructor function that returns the resource and the error. This shifts the responsibility of error handling to the caller.
func NewDatabaseConnection(dsn string) (*sql.DB, error) {
conn, err := sql.Open("postgres", dsn)
if err != nil {
return nil, fmt.Errorf("connection failed: %w", err)
}
if err = conn.Ping(); err != nil {
return nil, fmt.Errorf("database unreachable: %w", err)
}
return conn, nil
}
This design allows for dependency injection, clearer error management, and easier mocking in tests.
Appropriate Use Cases for init
While resource initialization should be avoided in init, it is suitable for static configuration or registering types. For instance, configuring HTTP routes or registering a driver where the outcome is deterministic and stateless is a valid use case.
func init() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
}
This usage is acceptable because it does not involve network calls during initialization that might fail unexpectedly, nor does it rely on global mutable state in a way that complicates testing.