skip to content

What is wrong with a package-level `var apiURL = os.Getenv("API_URL")` in a Go package?

level: juniorimportance: must knowfreq 68%

answer

  1. it runs before main, not on first use
  2. an initializer cannot return an error
  3. every importer pays, even unused ones
  4. a test can only change it by assignment
  5. read in main, store on a struct

basics

~20 s

It runs at package initialization, before main, so nothing can supply or check the value: the package cannot report a missing setting as an error, and a test can only change it by writing to a global.

solid answer

~40 s

A package-level variable's initializer runs when the package is initialized, before `main` starts. That has four consequences. There is no error path: if `API_URL` is empty the package either carries on with an empty string or panics before any logging is configured. Nothing can override it: a test cannot re-run the initializer, so it has to assign to the global and put the old value back, which couples tests to each other. Every importer pays, including test binaries and code generators that never call the code. And the value is frozen for the life of the process. The fix is to read the environment once in `main`, validate it there, and pass it into a constructor that stores it on a struct, so callers and tests supply their own value as a parameter.

code

go · 14 lines
go
// Anti-pattern: runs before main, cannot report an error, one value per process.
var apiURL = os.Getenv("API_URL")

// Fix: the caller supplies the value and gets a real error back.
type Client struct {
	apiURL string
}

func NewClient(apiURL string) (*Client, error) {
	if apiURL == "" {
		return nil, errors.New("API URL must not be empty")
	}
	return &Client{apiURL: apiURL}, nil
}

go deeper

for a junior

Be ready to say when a package-level variable's initializer runs: at package initialization, before main, once per process. Then name the two things it costs you — no error can be returned, and nothing can supply a different value.

for a middle

Explain the mechanics: initialization order runs before main, so a flag value is not available yet and t.Setenv comes too late. Show the rewrite into a constructor that validates its parameter and returns an error.

for a senior

Show the production consequence: a missing setting becomes a runtime panic with no logging configured, and every test that needs a different value mutates shared state. Talk about how you would spot this in review before it spreads.

for a principal

Own the standard. Decide which package-level values your codebase blesses, such as sentinel errors and lookup tables, and which it forbids, and explain how a rule like that gets enforced rather than just written down.

## The slot this code runs in Go initializes a package before any of its functions can be called: the packages it imports are initialized first, then its package-level variables, then its `init` functions, and `main` runs last. So `var apiURL = os.Getenv("API_URL")` is not "code that runs when I use the package" — it is code that runs during startup of any binary that transitively imports the package, whether or not anything ever reads `apiURL`. That single fact produces every objection a reviewer will raise. ## 1. There is no error path A variable initializer is an expression and an `init` function returns nothing. Neither can return an error. So a missing or malformed setting leaves you with exactly two options: silently continue with the zero value (an empty URL, a zero timeout, a nil client) and fail confusingly much later, or `panic`. A panic here happens before `main`, which means before flags are parsed, before logging is configured, and before any of your own error reporting exists. The operator gets a stack trace out of the runtime instead of "API_URL is not set". Compare that with reading the value in `main`: `url, ok := os.LookupEnv("API_URL")` gives you a boolean you can act on, a place to print a usage message, and a controlled exit code. ## 2. Nothing can override it Because initialization happens once per process, at import time, there is no second chance. A test cannot arrange for a different value before the initializer runs — the test binary initialized the package before the first test function was called. `t.Setenv` is therefore useless for this variable: it sets the environment long after the read already happened. The only remaining lever is assigning to the global from a test and restoring it afterwards. That works, and it is exactly what makes a package's tests order-dependent: state left behind by one test changes the behaviour of the next, and tests that call `t.Parallel` can be running while another test is mutating the same variable, which is a genuine data race. "Just export it so tests can set it" is not a fix; it is the same problem with a capital letter. ## 3. Every importer pays Import-time work is charged to everyone in the build. If the initializer only calls `os.Getenv` the cost is trivial, but this pattern grows: today it is an environment read, next month it is a file read, a DNS lookup, or a database dial. Then a code generator or a small unit-test binary that imports the package for one helper function fails to start because a network dependency is unreachable. Import-time work also removes the linker's freedom to drop unused code, since initialization has observable side effects. ## 4. The value is frozen for the process lifetime One value, one process, no second instance. You cannot construct two clients pointing at two environments in a single test, and you cannot support a config reload later without changing every caller. ## A related trap: flags The same slot explains why a package-level variable cannot hold a parsed flag value. `flag.Parse` is called from `main`, long after package variables are initialized, so anything computed from a flag at package level sees the default. Only the pointer form works, and only because you dereference it after `Parse` has run. ## The shape that works Move the read to the edge and the value into a value: - `main` reads the environment, validates it, and reports a clear error if it is missing. - A constructor takes the value as a parameter, checks it, and returns `(*Client, error)`. - Methods on the struct use the field. Now a test constructs a client with whatever URL it wants, two clients can coexist, tests can run in parallel, and the failure message for a missing setting is written by you rather than by the runtime. ## What is still fine at package level This is not a rule against every package-level variable. Immutable values are idiomatic and expected: sentinel errors that callers compare against, lookup tables, and other data that is computed once and never written. The objection is specifically to package-level state that is mutable, environment-dependent, or produced by work that can fail. If a reviewer can ask "what would a test have to do to change this?" and the only answer is "assign to the variable", the design is wrong.

  • Is it any better to move the os.Getenv call into an init function instead of the variable's initializer?
    No. `init` runs in the same startup slot, still cannot return an error, and still writes a package-level variable. The only thing it buys you is room for an `os.LookupEnv` check and a panic with a readable message — and that panic still fires before `main`, before logging is configured and before you can print usage.
  • Why can a package-level variable not be initialized from a command-line flag's value?
    Package-level variables are initialized before `main` runs, and `flag.Parse` is called inside `main`. At initialization time the flag still holds its default, so the variable captures the default forever. Storing the pointer returned by `flag.String` works only because you dereference it after `Parse` has run.
  • Once the value lives on a struct, how does a test supply a different one?
    It calls the constructor with the value it wants. There is no global to save and restore, no cleanup to forget, and two tests can hold two differently configured clients at the same time, so they can run in parallel without interfering.

It is like hard-coding the address into the factory that stamps the envelopes, instead of writing it on each envelope: every letter goes to the same place, and to test a different address you have to retool the factory.

saying these in an interview costs you the question

  • Environment variables never change, so a global is safe
  • Globals are fine as long as nothing writes to them after startup
  • Just export the variable so tests can assign to it
  • Wrap it in a getter function and it is properly encapsulated
  • Call t.Setenv in the test to change what the initializer read
  • Panicking from init on a missing setting is always the right failure mode