Go init() Function — Find the Bug¶
Instructions¶
Each exercise contains buggy Go code involving init. Identify the bug, explain why it happens, and provide the corrected code. Difficulty: Easy, Medium, Hard.
Bug 1 (Hard) — Goroutine Race in init¶
package main
import (
"fmt"
"time"
)
var counter int
func init() {
go func() {
for i := 0; i < 1000; i++ {
counter++
}
}()
}
func main() {
time.Sleep(10 * time.Millisecond) // hope goroutine finished
fmt.Println("counter:", counter)
}
What's wrong? Run with -race. What does the output look like?
Solution
**Bug**: A goroutine is spawned in `init` that mutates a package-level variable. `main` reads the same variable. There is no synchronization between them. The race detector flags this as a data race. Output of `counter` is non-deterministic — sometimes 0, sometimes 1000, sometimes a partial value. Even worse: when `main` starts, the goroutine has not finished. The 10ms sleep is a band-aid, not a guarantee. **Why init is the wrong place**: Spawning goroutines from init couples the package's import to spawning a worker. Every importer (including tests) gets that goroutine, with no way to opt out. **Fix** — explicit start:package main
import (
"fmt"
"sync"
)
var (
counter int
counterMu sync.Mutex
wg sync.WaitGroup
)
func startCounter() {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 1000; i++ {
counterMu.Lock()
counter++
counterMu.Unlock()
}
}()
}
func main() {
startCounter()
wg.Wait()
counterMu.Lock()
defer counterMu.Unlock()
fmt.Println("counter:", counter)
}
Bug 2 (Hard) — init Reading Uninitialized Cross-Package Var¶
// pkg/a/a.go
package a
import "yourmodule/pkg/b"
var Cache map[string]int
func init() {
Cache = map[string]int{}
for _, k := range b.Keys { // b.Keys may not be filled yet?
Cache[k] = len(k)
}
}
// pkg/b/b.go
package b
import "yourmodule/pkg/a" // <-- circular!
var Keys = []string{"alpha", "beta", "gamma"}
func init() {
_ = a.Cache // tries to use a's cache
}
What's wrong?
Solution
**Bug**: Circular import. Package `a` imports `b`, and `b` imports `a`. Go forbids this — the compiler emits `import cycle not allowed`. **Why this is a bug class**: New developers sometimes try to break apart shared state by adding cross-imports. The compiler stops them. But subtler variants exist where you have a third package C imported by both A and B, and you assume A's init has completed when B's init runs (or vice versa). Because the import graph determines order, you can have surprising results. **Fix** — restructure: `b` either disappears or imports `keys` directly. No cycle. **Key lesson**: If you find yourself wanting circular imports for init coordination, the design is wrong. Extract a shared dependency package (often holding only data) and have both consumers import it.Bug 3 (Medium) — init Panic, No Recovery¶
package main
import (
"fmt"
"log"
"os"
)
func init() {
if os.Getenv("API_KEY") == "" {
panic("API_KEY not set")
}
}
func main() {
defer func() {
if r := recover(); r != nil {
log.Println("recovered:", r) // never runs!
}
}()
fmt.Println("started")
}
Why doesn't recover catch the init panic?
Solution
**Bug**: `recover` only catches panics in the same goroutine, in a frame above the deferring function. The init panic propagates up `runtime.main`, which is **above** `main.main` in the stack. By the time control would reach the deferred recover in `main.main`, the program has already aborted with status 2. **Why this matters**: A common misunderstanding is "I'll just defer recover in main and catch any init panic." You can't. Init failures crash the program before main is reached. **Fix** — handle the missing key in main:package main
import (
"fmt"
"log"
"os"
)
var apiKey string
func loadConfig() error {
apiKey = os.Getenv("API_KEY")
if apiKey == "" {
return fmt.Errorf("API_KEY not set")
}
return nil
}
func main() {
if err := loadConfig(); err != nil {
log.Printf("config error: %v", err)
os.Exit(1) // graceful exit code, structured log
}
fmt.Println("started")
}
Bug 4 (Medium) — Cross-File Init Order Reliance¶
// File: aaa_setup.go
package mypkg
import "fmt"
var registered int
func init() {
fmt.Println("aaa init: registered =", registered)
registered++
}
// File: zzz_setup.go
package mypkg
import "fmt"
func init() {
fmt.Println("zzz init: registered =", registered)
registered++
}
What does this print? Why is the assumption fragile?
Solution
**Output (current Go toolchain)**: **The "bug" hidden inside**: This works because the toolchain processes `aaa_setup.go` before `zzz_setup.go` (alphabetical). The Go spec guarantees **deterministic** order, but: - It does not guarantee **alphabetical filename** order. (Older Go vesions and `gccgo` may differ.) - A renamed file changes the order silently. - `goimports` and IDEs sometimes reorder things. If a developer renames `aaa_setup.go` to `setup.go`, `zzz_setup.go` may now run first, and the output flips to: **Fix** — don't rely on filename order. Either: 1. Put both inits in the same file:// File: setup.go
package mypkg
import "fmt"
var registered int
func init() { fmt.Println("first init"); registered++ }
func init() { fmt.Println("second init"); registered++ }
Bug 5 (Hard) — Heavy I/O in init Hangs Tests¶
package store
import (
"database/sql"
_ "github.com/lib/pq"
"log"
"os"
)
var DB *sql.DB
func init() {
var err error
DB, err = sql.Open("postgres", os.Getenv("DSN"))
if err != nil {
log.Fatal(err)
}
if err := DB.Ping(); err != nil {
log.Fatal(err)
}
}
A new developer runs go test ./... on their laptop. They have no Postgres running. What happens?
Solution
**Bug**: Every test binary that transitively imports `store` calls `DB.Ping()`. With no DB: - TCP connection times out (5-30 seconds default). - `Ping` returns an error. - `log.Fatal` exits the test binary with code 1. The developer sees: It looks like an unrelated test failed. They lose hours debugging. **Multiplied across packages**: in a 50-package project, even unrelated tests fail because they all link `store`. **Fix** — lazy:package store
import (
"database/sql"
_ "github.com/lib/pq"
"errors"
"os"
"sync"
)
var (
once sync.Once
db *sql.DB
dbErr error
)
func DB() (*sql.DB, error) {
once.Do(func() {
dsn := os.Getenv("DSN")
if dsn == "" {
dbErr = errors.New("DSN env var not set")
return
}
db, dbErr = sql.Open("postgres", dsn)
if dbErr == nil {
dbErr = db.Ping()
}
})
return db, dbErr
}
Bug 6 (Easy) — Multiple inits Misunderstood¶
package main
import "fmt"
func init() {
fmt.Println("first")
}
func init() {
fmt.Println("second")
}
func init() {
fmt.Println("third")
}
func main() {
fmt.Println("main")
}
A developer claims this won't compile because Go only allows one init per file. Are they right?
Solution
**Verdict**: They are wrong. Go allows arbitrarily many `init` functions per file, and per package. They run in the order they appear in source. **Output**: **Why the misconception**: Most languages with similar features (Java's static initializers, C# constructors) have one. Go is unusual. **Spec quote** (https://go.dev/ref/spec#Package_initialization): > "Multiple such functions may be defined per package, even within a single source file." **When it's useful**: separating unrelated init steps for clarity: vs: Both work. The first is sometimes preferred for top-level grep-ability — each init can be reasoned about independently. **Key lesson**: Multiple inits per file is legal and idiomatic. Don't refactor them into one unless there's a real reason.Bug 7 (Easy) — Trying to Test init Directly¶
// File: registry.go
package myreg
var Items []string
func init() {
Items = append(Items, "alpha", "beta")
}
// File: registry_test.go
package myreg
import "testing"
func TestInit(t *testing.T) {
Items = nil
init() // <-- compile error
if len(Items) != 2 {
t.Errorf("want 2, got %d", len(Items))
}
}
Why doesn't the test compile? How do you make init's logic testable?
Solution
**Bug**: `init` is **not** a name in the program's scope. The compiler error is: You cannot call init by name. You cannot reference it. It exists in the symbol table but is unaddressable from user code. **Fix** — extract the body to a named function: You can now: - Call `setupRegistry` from tests with a known starting state. - Reset `Items` between tests (use `t.Cleanup` to restore). - Inject test data by parameterizing `setupRegistry` if needed. **Key lesson**: For any init body you might need to test, factor it into a named function. The init function itself becomes a one-line trampoline.Bug 8 (Medium) — Receiver Method Named init¶
package main
import "fmt"
type Server struct {
name string
}
func (s *Server) init() {
s.name = "default"
fmt.Println("Server init")
}
var srv = &Server{}
func main() {
fmt.Println("server name:", srv.name)
}
What does this print? Why?
Solution
**Output**: **Bug**: `func (s *Server) init()` is a **method** on `*Server`. It is NOT the magic `init`. The runtime never calls it. The compiler doesn't even consider it for the init pipeline. The empty string for `name` reflects the zero value of `string` — `init` was never invoked. **Fix** — pick one approach: (a) Make a true package init that initializes `srv`: (b) Have an explicit constructor: (c) Make the method explicit and call it: **Key lesson**: Only top-level `func init()` (no receiver) is special. Methods named `init` are silently ignored by the runtime. This is a common gotcha for developers from Java/C# backgrounds.Bug 9 (Medium) — Blank Import Removed by Tooling¶
package main
import (
"database/sql"
"fmt"
// _ "github.com/lib/pq" // <-- accidentally removed
)
func main() {
db, err := sql.Open("postgres", "...")
if err != nil {
fmt.Println("error:", err)
return
}
_ = db
}
What's the runtime error?
Solution
**Error**: The error is famous and the second half ("forgotten import?") is a literal hint built into `database/sql`. **Why it happens**: `database/sql` has no built-in postgres driver. The driver registers itself in its `init`. Without the blank import, `pq.init` never runs, and `sql.Open("postgres", ...)` cannot find the driver. **Why tooling removes it**: - `goimports` removes unused imports. If the import is `_ "..."`, it's actually used (by side effect), and `goimports` should keep it. But buggy editor configs sometimes strip blank imports incorrectly. - A junior developer might remove the line manually thinking it's unused. **Fix**: The comment is essential. Many style guides require it for every blank import. **Key lesson**: Blank imports are load-bearing. Always document them with a comment. Some teams enforce this with a custom lint rule that flags any uncommented blank import.Bug 10 (Hard) — flag.Parse in Library init¶
// File: pkg/logger/logger.go
package logger
import "flag"
var verbose = flag.Bool("v", false, "verbose mode")
func init() {
flag.Parse() // <-- bug
}
// File: main.go
package main
import (
_ "yourmodule/pkg/logger"
"flag"
"fmt"
)
var name = flag.String("name", "world", "name to greet")
func main() {
fmt.Printf("hello %s\n", *name)
}
What goes wrong when you run ./prog -name=Alice?
Solution
**Bug**: The library's `init` calls `flag.Parse()`. This happens **before** `main` has a chance to define its own flags. So when `flag.Parse` runs: 1. Only `-v` is registered (defined by the library). 2. The CLI argument `-name=Alice` is unknown. 3. `flag.Parse` calls `flag.usage` and `os.Exit(2)`. Output: The user can't even reach `main()`. **Why `flag.Parse` doesn't belong in init**: - It mutates global state (`flag.CommandLine`). - It runs at an unpredictable time relative to other packages' flag definitions. - It's a `main`-package responsibility. **Fix** — let `main` parse: **Key lesson**: Libraries should never call `flag.Parse`. Define flags as package vars (which auto-register on import), and let `main` parse.Bug 11 (Hard) — init Modifies Map, Concurrent Read in Goroutine¶
package metrics
import "fmt"
var labels = map[string]string{}
func init() {
go func() {
for k, v := range labels { // race: read while init may still modify
fmt.Println(k, v)
}
}()
labels["host"] = "localhost"
labels["env"] = "prod"
}
What's the bug? Run with -race.
Solution
**Bug**: The `go func()` ranges over `labels`. The init body, after the `go` statement, mutates `labels`. The race detector flags concurrent map access. Output is non-deterministic — the goroutine may see 0, 1, or 2 entries; the runtime may also panic with "concurrent map iteration and map write". **Why init is the wrong place**: Same theme as Bug 1 — goroutines spawned in init have no defined relationship with the rest of init or with `main`. **Fix** — defer the goroutine to main: In main: **Key lesson**: A second time, with feeling: don't spawn goroutines in init. They race with init's own body and with main.Bug 12 (Medium) — init Order Across Packages Misunderstood¶
// pkg/a/a.go
package a
import "yourmodule/pkg/b"
var Greeting = "hi from a, b says: " + b.Greeting
func init() {}
A developer worries: "What if a.Greeting is computed before b.Greeting?" Reassure them with the rules.
Solution
**Reassurance**: The Go spec guarantees: 1. Imported packages init **fully** before the importer. 2. Within a package, vars init in **dependency order** (topological sort, deterministic). So: - `b` initializes fully first (it has no imports). - `b.Greeting = "hi from b"` - `b.init()` runs. - Then `a` initializes: - `a.Greeting = "hi from a, b says: hi from b"` - `a.init()` runs. - Then `main`: - `main.init()` (none here). - `main.main()` runs. Output: `hi from a, b says: hi from b`. **However**: the assumption only holds for **package-level vars** with **statically detectable** dependencies (like `b.Greeting` referenced directly). If `a.Greeting` were: where `b.someVarSetByInit` is set inside `b`'s `init`, this still works because B's init runs before A's vars are initialized — IF the compiler can see the dependency. The Go compiler does **whole-package analysis** and orders correctly. But cross-package dependencies that go through `init` side effects (rather than direct var refs) are not analyzed and rely solely on import order. **Key lesson**: Direct cross-package var references are safe and ordered. Init-side-effect dependencies (init in B writes to a global that A's init reads) work via import ordering, but are fragile to refactoring.Cheat Sheet — Init Bug Patterns¶
| Bug | Symptom | Fix |
|---|---|---|
| Goroutine in init | Race detector triggers; non-deterministic | Move to explicit Start() in main |
| Cross-package init dependency | Brittle; refactor breaks order | Extract shared dependency package |
| Panic in init | Program aborts before main; no recover | Validate in main |
| Cross-file init reliance | Filename rename changes behavior | Same-file inits or topo-sort vars |
| Heavy I/O in init | Tests slow/fail | sync.Once lazy |
| init() called explicitly | Compile error: undefined | It can't be called by name |
| Method named init | Silently ignored | Use top-level func init |
| Removed blank import | "unknown driver" runtime | Comment & lock down |
| flag.Parse in library init | CLI flags break | Move to main |
| Map race with init goroutine | Race or panic | Don't spawn from init |
These bugs cover the realistic spectrum of init misuse. Internalize the patterns: most surface in code review.