In Go, why is a package-level var that reads a config value zero when main.main loads that config?
answer
- ask when the expression was evaluated
- two phases, and main is the second
- the initialiser ran exactly once, early
- reading a nil map returns zero, no panic
- the compiler cannot order you against main
basics
~20 sPackage-level variables and init functions all finish before main.main is called, so the variable was computed while the config was still empty and captured a zero value. Nothing recomputes it later when main fills the config in.
solid answer
~50 sIt is a startup-ordering bug, not a config bug. Every package-level variable initialiser and every `init` function in the import graph runs to completion before `main.main` gets control, so anything `main` does - reading a file, parsing flags, dialling a database - happens strictly afterwards. A variable such as `var Timeout = Config["timeout"]` is evaluated once, during initialisation, against whatever `Config` held then, which is its zero value. Dependency ordering does not save you: the compiler orders initialisers against each other by the references it can see, and it cannot see that `main` will later populate the map. The fix is to stop computing the value at import time. Load the config in `main`, pass it to a constructor that returns a value and an `error`, and hand the result to whatever needs it - which also gives you somewhere to report a bad config value.
code
go · 6 lines// Config is filled by LoadConfig, which main.main calls.
var Config map[string]string
// This initialiser runs before main.main, when Config is still nil.
// Reading a nil map is legal, so Timeout is "" for the whole process.
var Timeout = Config["timeout"]go deeper
Fix the sequence in your head: package-level variables and init functions all run before main. A value computed there cannot see anything main does afterwards, and it is never recomputed.
Explain why the compiler's dependency ordering does not help - it only orders package-level initialisers against each other - and why the zero value is silent here, since reading a nil map does not panic.
Show how you would find it and prevent it: a zero-valued timeout or empty URL that was wrong from the first request, traced back to an import-time initialiser, and a move to constructors that take config and return an error.
Own the standard that keeps this from recurring: configuration flows in as parameters from main, and package-level state that depends on configuration is treated as a defect rather than a convenience.
## The failure A service reads its settings from a file at startup. Somewhere in a package there is a convenient-looking declaration: ```go var Config map[string]string // filled by LoadConfig, called from main var Timeout = Config["timeout"] // always "" ``` The config file is correct, `LoadConfig` works, and yet `Timeout` is empty for the life of the process. Nothing about maps or config parsing explains it - the explanation is the startup sequence. ## Why it happens Go initialises a program in a fixed order: 1. The runtime bootstraps. 2. Every package in the import graph is initialised - package-level variables first, then `init` functions - in dependency order, imports before importers, `main` last. 3. `main.main` is called. Step 2 finishes entirely before step 3 begins. So *anything* `main` does is later than *every* package-level initialiser in the program. `Timeout`'s initialiser ran in step 2 and read a nil map, which yields the zero value for the element type - and reading a nil map is legal, so there is no panic to point at the mistake. `LoadConfig` then populated `Config` in step 3, long after the only evaluation of `Timeout` that will ever happen. The second half of the misconception is about dependency ordering. The compiler does order initialisers by their references: if `var b = f(a)` and `a` has its own initialiser, `a` is initialised first. But that ordering is a relationship *among package-level initialisers*. There is no edge from `Timeout` to "whatever `main` does later", because `main` runs in a different phase entirely. ## Why the zero value is so easy to miss Go's zero values are usable rather than explosive, which hides this class of bug: - reading a **nil map** returns the element's zero value and `ok == false`, without panicking; - a **zero `time.Duration`** is a legal duration - so a timeout of zero often means *no timeout* or *immediate deadline*, either of which is a production incident rather than a crash; - an **empty string** URL, a **nil** slice, a **nil** pointer waiting to be dereferenced later. So the symptom shows up far from the cause: a client with no timeout, an empty base URL, a feature flag that is always false. ## The fix Stop computing values at import time. Make the dependency explicit in code that runs when you want it to: - `main` loads the configuration and gets an `error` if it is bad; - a constructor takes the configuration as a parameter, validates it, and returns the built value plus an `error`; - `main` passes that value into whatever needs it, rather than packages reaching for a global. This also fixes a second problem the original shape has: `init` and package-level initialisers have no way to report failure. `var Timeout = mustParse(Config["timeout"])` can only panic - before any logger exists - whereas `NewServer(cfg) (*Server, error)` can return a message naming the offending key, and `main` can exit cleanly with it. ## Related traps in the same phase - **Flags.** `os.Args` is populated before initialisation, but `flag.Parse` has not been called, so a package-level variable reading a flag's `Value` sees the default. The same ordering, one layer over. - **Environment.** `os.Getenv` *does* work at initialisation time, which is why environment-based config appears to work in exactly the situation where file-based config does not - and then breaks the day someone moves a setting from an environment variable into the config file. - **Tests.** A test binary initialises the same packages before `TestMain` or any test runs, so a value captured at import time cannot be swapped out per test either; that is one of the reasons import-time computation is hard to test. ## Recognising it fast If a value is wrong from the very first use and is always the zero value, ask when it was computed. If the answer is "in a package-level declaration", the ordering explains it. Running with `GODEBUG=inittrace=1` confirms how much work the program does before `main` gets a chance to load anything.
- Would moving the assignment into an init function in the same package fix it?No. `init` functions run in the same phase as package-level variable initialisers - after them, but still before `main.main`. The config is loaded later either way, so the value is still zero. The phase is the problem, not the syntax.
- Why does reading an environment variable at package level appear to work when reading a config file does not?The environment is captured by the runtime before package initialisation, so `os.Getenv` returns real data during `init`. A config file is only read when `main` calls your loader, which is after every initialiser has run. That difference is why moving one setting from an environment variable into a file can silently break it.
- How would you catch this class of bug in review rather than in production?Treat any package-level variable whose initialiser calls a function - especially one reading a global, a file or a flag - as a smell. Values that depend on configuration should be parameters of a constructor called from `main`, so the dependency is visible in the signature and a wrong value returns an error.
saying these in an interview costs you the question
- Says the variable will pick up the config on next read
- Blames the config loader instead of the startup ordering
- Thinks moving the code into init fixes the ordering
- Expects a panic because the map was nil
- Believes dependency ordering covers work done in main