skip to content

Your Go codebase is full of package-level mutable state and you have one migration budget. What changes first?

level: principalimportance: nice to knowfreq 28%

answer

  1. rank by cost, not by purity
  2. some package variables are fine, say which
  3. the inflow matters more than the backlog
  4. the stdlib pairs globals with constructors
  5. a convention leaves when its author does

basics

~20 s

Spend the budget on the globals that cost you daily: mutable state tests must swap, and import-time work that can fail. Bless immutable package values, write the rule for new code first, and enforce it in CI.

solid answer

~50 s

Rank by what the globals actually cost. Top of the list is mutable state that tests have to swap, because it is what makes the suite order-dependent and unparallelizable, and it pays back every day. Next is import-time work that can fail or block, since it robs `main` of an error path and taxes every binary that imports the package. Immutable package-level values such as sentinel errors and lookup tables are idiomatic and I leave them alone. Because a full migration of a large codebase never finishes, I write the rule for new code first and convert existing packages on touch, starting with the ones under active churn. The rule I ask for is the standard library's own shape: any global must be a thin default over something constructible, the way `http.DefaultServeMux` sits beside `http.NewServeMux`. And I enforce it with a check in CI, because conventions leave when their author does.

go deeper

for a junior

Be ready to name one package-level variable your team relies on and say concretely what it makes hard, such as writing a test that needs a different value for it.

for a middle

Explain why import-time work costs every importer and why swapping a global in a test couples tests together. Show that you can distinguish an immutable package value from mutable shared state.

for a senior

Argue a ranking and defend what you leave alone. Show how you would convert a package on touch rather than in a branch, and what mechanical check you would add so the next violation fails in CI.

for a principal

Own the policy and its enforcement: the rule for new code, the grandfathered baseline, the exception test that every global be paired with something constructible, and the measurement that decides whether to keep spending the budget.

## The question behind the question This is not "are globals bad". It is a resource allocation call with an ongoing enforcement problem attached, and the failure modes are predictable: a team either declares a big-bang refactor that stalls at thirty percent, or writes a style guide nobody applies. A good answer commits to a ranking, defends what it deliberately leaves alone, and says how the rule survives the person who wrote it. ## Rank by cost, not by purity Not every package-level variable is worth the same. Three tiers: **Tier 1 — mutable state that tests must swap.** A package-level client, a configuration struct, a clock function, a registry map. This is what makes a suite order-dependent, blocks parallel tests, and produces the intermittent CI failures that eat engineer-hours weekly. It is also the tier with the clearest payback, because fixing it usually makes the tests both stable and faster. **Tier 2 — work done at import time that can fail or block.** Reading and validating the environment, opening a file, dialling a service, starting a goroutine. It removes `main`'s ability to report a clear error, and it taxes every binary that imports the package transitively, including test binaries and code-generation tools that never call the code. **Tier 3 — immutable package-level values.** Sentinel errors that callers compare against, lookup tables, precomputed data. These are idiomatic Go and there is no reason to touch them. Saying so out loud matters: a rule that forbids everything is a rule engineers route around, and it costs you credibility on the tiers that matter. ## Write the rule for new code before you migrate any old code A large codebase's global count grows faster than a migration removes it unless the inflow stops. So the first deliverable is the rule, not a refactor: - No package-level variable of a mutable type, except a registry-style default that is paired with a constructor. - `init` may not do I/O, read the environment, start goroutines, or panic on configuration. - Blank imports for registration are allowed only for the narrow case they exist for: packages that must not import each other and a plugin set that is genuinely open-ended. Then convert on touch. A package being actively worked is cheap to convert and the change is reviewed by people who know it. A package nobody has opened in two years is expensive to convert and the conversion carries risk for no benefit. ## The test that decides each exception Borrow the standard library's shape. Go's own globals are almost always a convenience layer over something you can construct: `http.DefaultServeMux` beside `http.NewServeMux`, `flag.CommandLine` beside `flag.NewFlagSet`, a default logger beside `log.New`. The global is a default, not the only way in. So the rule for an exception is: the functionality must be reachable without the global. An author who wants an `init`-populated registry can have one if the registry is also a type that a caller — and a test — can instantiate and populate explicitly. That converts most arguments from a values debate into a small API change, which is a much easier conversation in review. When someone cites `database/sql` driver registration as precedent, the answer is that it earns its globals through constraints your subcommand registry does not have: the driver and the consumer must not import each other, the implementations are third-party, and selection happens by name at run time. ## Enforcement, or it did not happen A checklist in a document is enforced by whoever remembers it, which means it decays on a predictable schedule. Two mechanisms that survive: - A static check in CI. An analyzer over the syntax tree, written with the standard analysis tooling in the `golang.org/x/tools` repositories, can flag new package-level variables of mutable types and `init` functions containing I/O, with a suppression comment that requires a named owner. - Shuffled tests in CI. Turning shuffling on makes a new ordering dependency fail on the pull request that introduces it. That converts the abstract rule into a concrete, immediate failure, which is far more effective than review. Grandfather the existing violations into a baseline so the check is green on day one and only new code is blocked. A check that starts red is a check that gets disabled. ## What you measure Justify the budget with something observable: the rerun rate of the test suite, wall-clock CI time, and the count of suppressions. If the rerun rate does not fall after the tier-1 conversions, your ranking was wrong and you should say so rather than continue on principle. ## What you explicitly do not do You do not ban all package-level variables. You do not open a refactor branch across two hundred packages. You do not accept "add a mutex and it is safe", because a mutex fixes the race and leaves the testability and the lifetime problems exactly where they were. And you do not leave it to each team's taste, because the cost of these globals is paid in a shared CI pipeline that every team waits on.

  • Which package-level variables would you explicitly bless in the rule?
    Immutable ones: sentinel errors callers compare against, lookup tables, precomputed data, and a global that is only a convenience default over a constructor. Naming the allowed cases is what keeps the rule credible; a policy that forbids everything gets ignored wholesale, including on the cases that actually hurt.
  • An author argues an init-populated registry is idiomatic because the standard library registers database drivers that way. How do you respond?
    That case earns it: the driver and the consumer must not import each other, the implementations come from third parties, and selection is by name at run time. Inside one binary you own, none of that applies. I would approve the registry only if it is also a type a test can construct and populate itself.
  • How do you keep the rule from decaying once you move on?
    Put it in CI, not in a document. A static check with a grandfathered baseline blocks new violations while leaving existing ones alone, and shuffled tests make new ordering dependencies fail on the pull request that adds them. Each suppression carries a named owner so the exception list stays reviewable.
  • What would tell you the migration was not worth continuing?
    Flat numbers. If test rerun rates and CI wall-clock time do not move after converting the tier-one globals, the cost was somewhere else and I should stop and re-measure rather than keep spending budget on a principle. Saying that up front is what makes the budget request credible.

saying these in an interview costs you the question

  • Ban every package-level variable
  • Open one big refactor branch across the whole codebase
  • Globals are fine once you put a mutex around them
  • It is a style preference, let each team decide
  • Add a getter and the global is encapsulated
  • Write it in the style guide and rely on reviewers