DEV Community

Bala Paranj
Bala Paranj

Posted on

Global State in Go: 5 Kinds We Found, 3 We Eliminated, 2 We Kept

A mutable registry populated by init(), a session field leaking between evaluations, a validation limit set by a setter, an immutable singleton, and a cached detection result — how to tell which globals are dangerous and which are acceptable.

Not all global state is bad. A var ErrNotFound = errors.New("not found") is global state. So is var controlIDPattern = regexp.MustCompile(...). Nobody argues these should be parameters.

The problem is when global state is mutable and shared across execution boundaries — when one function writes it and another reads it without explicit coordination. In a CLI, this means two evaluations in the same process see each other's side effects. In tests, it means parallel tests corrupt each other's state.

We found 5 kinds of global state in a Go security CLI. Three were dangerous. Two were acceptable. Here's how we decided.

Dangerous: Mutable Global Populated by init()

The Problem

// 14 init() functions across 14 files, all writing to this:
var ControlRegistry = NewRegistry()

// access_block_public.go
func init() {
    ControlRegistry.MustRegister(&accessBlockPublic{
        Definition: NewDefinition(
            WithID("ACCESS.001"),
            WithSeverity(policy.SeverityCritical),
        ),
    })
}

// audit_server_logging.go
func init() {
    ControlRegistry.MustRegister(&auditServerLogging{ ... })
}

// ... 12 more files
Enter fullscreen mode Exit fullscreen mode

14 init() functions write to a package-level ControlRegistry during import. The consequences:

  • No test isolation. Every test that imports compliance gets all 14 controls loaded. Can't test with a subset.
  • No parallel safety. Two parallel tests share the same registry. If one test modifies the registry (registering a test-only control), the other sees the modification.
  • No ordering control. Go doesn't guarantee init() execution order across files. Adding a control that depends on another being registered first is fragile.
  • Hidden side effects. Importing a package changes global state. The import statement import _ "pkg/compliance" has invisible consequences.

The Fix: Factory Closures + On-Demand Catalogs

var ControlRegistry = NewRegistry()

var allControlConstructors []func() Control

func RegisterControl(factory func() Control) {
    allControlConstructors = append(allControlConstructors, factory)
    ControlRegistry.MustRegister(factory())
}

// Test-only: create an isolated catalog from the same factories
func NewTestCatalog() *ControlCatalog {
    cat := NewRegistry()
    for i := range allControlConstructors {
        cat.MustRegister(allControlConstructors[i]())
    }
    return cat
}

// Test-only: create a catalog with specific controls only
func NewCatalogWith(controls ...Control) *ControlCatalog {
    cat := NewRegistry()
    for _, ctl := range controls {
        cat.MustRegister(ctl)
    }
    return cat
}
Enter fullscreen mode Exit fullscreen mode

Each init() now registers a factory closure instead of a concrete instance:

func init() {
    RegisterControl(func() Control {
        return &accessBlockPublic{
            Definition: NewDefinition(
                WithID("ACCESS.001"),
                WithSeverity(policy.SeverityCritical),
            ),
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

Production is unchanged: ControlRegistry is still populated at import time. Existing code that reads ControlRegistry.Lookup("ACCESS.001") works as before.

Tests are now isolated: NewTestCatalog() creates a fresh catalog by calling each factory — new instances, no shared state. NewCatalogWith(myControl) creates a catalog with just one control for focused testing. Two parallel tests get two independent catalogs.

Dangerous: Session State on a Long-Lived Config Object

The Problem

type Runner struct {
    // Configuration (long-lived, set once)
    Controls     []policy.ControlDefinition
    Clock        func() time.Time
    SLAThreshold time.Duration

    // Session state (per-evaluation, mutable) — DANGER
    StaveVersion     string
    InputHashes      *evaluation.InputHashes
    identitiesByTime map[time.Time][]asset.CloudIdentity
}
Enter fullscreen mode Exit fullscreen mode

identitiesByTime is a mutable map set during Evaluate(). If the Runner is reused for a second evaluation, the identity map from run 1 leaks into run 2. The second evaluation sees identities from snapshots it didn't load.

StaveVersion and InputHashes are per-evaluation metadata on a struct that's supposed to be reusable configuration. Setting them before evaluation and reading them during report assembly creates a temporal coupling — the fields must be set in the right order.

The Fix: Separate by Lifetime

// Configuration (reusable, immutable after construction)
type Assessor struct {
    Controls     []policy.ControlDefinition
    Clock        ports.Clock
    SLAThreshold time.Duration
}

// Per-call parameters (passed as argument, not stored)
type AssessmentOptions struct {
    StaveVersion string
    InputHashes  *evaluation.InputHashes
}

// Per-evaluation state (created in Assess, garbage collected after)
type assessmentSession struct {
    assessor  *Assessor
    idIndex   IdentityIndex         // built fresh per evaluation
    collector *AssessmentCollector   // accumulates per evaluation
    auditTime time.Time             // computed per evaluation
    opts      AssessmentOptions
}
Enter fullscreen mode Exit fullscreen mode

identitiesByTime became IdentityIndex — a value type constructed fresh inside Assess() and stored on the session. When Assess() returns, the session is garbage collected. No leaking between evaluations.

The principle: separate by lifetime. Configuration lives for the process. Options live for one call. Session state lives for one evaluation.

Dangerous: Set-Once Global With No Coordination

The Problem

var maxValidationErrors = DefaultMaxValidationErrors

func SetMaxValidationErrors(n int) {
    if n > 0 {
        maxValidationErrors = n
    }
}
Enter fullscreen mode Exit fullscreen mode

A package-level variable set by a Set* function during bootstrap. The intent is "configure once at startup, read during validation." But nothing enforces the "once" part:

  • If two tests call SetMaxValidationErrors(5) and SetMaxValidationErrors(10), the last one wins for both tests.
  • If validation runs concurrently with SetMaxValidationErrors, the read is racy.
  • The function name doesn't communicate that it's a one-time bootstrap operation.

The Fix: Document the Contract

// maxValidationErrors controls how many errors are shown before truncating.
// Set via SetMaxValidationErrors at startup. This is a process-level display
// preference, not domain state — acceptable as a package variable since it's
// set once during bootstrap and read during validation.
var maxValidationErrors = DefaultMaxValidationErrors

// SetMaxValidationErrors overrides the validation error display cap.
// Must be called during process initialization (e.g., bootstrap), not
// concurrently with validation calls.
func SetMaxValidationErrors(n int) {
    if n > 0 {
        maxValidationErrors = n
    }
}
Enter fullscreen mode Exit fullscreen mode

We chose documentation over refactoring here because:

  • There's only one caller (cmd/bootstrap_limits.go)
  • The variable is a display preference, not domain logic
  • Moving it to a parameter would thread it through 5 functions that don't care about it

When this becomes a problem: If a second caller appears, or if tests need different values, refactor to pass the limit through the function call chain or via a ValidationConfig struct.

Acceptable: Immutable Singletons

The Pattern

var SafeResult = ComplianceReport{Exposed: false}

var GlobalScope = &AuditScope{global: true}

var ErrZeroTimestamp = errors.New("record observation: time must not be zero")

var controlIDPattern = regexp.MustCompile(`^CTL\.[A-Z][A-Z0-9]*(\.[A-Z][A-Z0-9]*){1,}\.\d{3}$`)
Enter fullscreen mode Exit fullscreen mode

Why these are fine:

  • Immutable. No Set* function. No mutation after package initialization. SafeResult is a value type — copying it creates an independent instance.
  • No side effects. Reading GlobalScope doesn't change state anywhere.
  • Compile-time constant behavior. ErrZeroTimestamp always returns the same error. controlIDPattern always matches the same strings.

The litmus test: If you can replace the global with a const (conceptually, even if Go doesn't support const for the type), it's safe. SafeResult is conceptually const ComplianceReport{Exposed: false}. ErrZeroTimestamp is conceptually const error("...").

Acceptable: Cached Detection Results

The Pattern

var ttyCache sync.Map

func CanColor(out io.Writer) bool {
    f, ok := out.(*os.File)
    if !ok {
        return false
    }
    key := reflect.ValueOf(f).Pointer()
    if cached, ok := ttyCache.Load(key); ok {
        v, _ := cached.(bool)
        return v
    }
    enabled := detectTTY(f)
    ttyCache.Store(key, enabled)
    return enabled
}
Enter fullscreen mode Exit fullscreen mode

Why this is fine:

  • Idempotent. detectTTY(f) returns the same result for the same file descriptor. Caching doesn't change behavior — just avoids redundant syscalls.
  • Thread-safe. sync.Map handles concurrent reads and writes correctly.
  • No test pollution. Two tests using os.Stdout get the same cached result — which is correct, because os.Stdout has the same TTY status in both tests. The IsTTY *bool override on Runtime allows tests to bypass the cache entirely.
var globalNoColor bool

func SetNoColor(v bool) { globalNoColor = v }
Enter fullscreen mode Exit fullscreen mode

Similar reasoning for globalNoColor: set once during bootstrap, read many times. The --no-color flag is process-level state — it doesn't change during execution. Tests that need different behavior use the Runtime.NoColor field, not the global.

The Classification Framework

Kind Mutable? Set When? Read When? Verdict
ControlRegistry + init() YES Import time (14 sites) Runtime (many) Eliminate — factory pattern
identitiesByTime on Runner YES Per-evaluation Same evaluation Extract — session struct
maxValidationErrors YES Bootstrap (1 site) Validation (few) Document — set-once contract
SafeResult, ErrZeroTimestamp NO Package init Anywhere Keep — immutable singleton
ttyCache YES (append-only) First access Subsequent access Keep — idempotent cache

The Three Questions

For each package-level var, ask:

1. Can two callers see different values?
If yes → eliminate. ControlRegistry shows 14 controls to test A and 14 controls to test B, but test A might want only 1.

2. Does mutation in one context leak into another?
If yes → extract. identitiesByTime from evaluation 1 leaked into evaluation 2.

3. Is the value set once and never changed?
If yes → keep (or document). globalNoColor, maxValidationErrors, compiled regexps.

The last question has a trap: set once is only safe if you can guarantee it's called before any reads. In a CLI, bootstrap runs before any command — safe. In a library used by multiple goroutines, set once is a race condition. Know your execution model.


These 5 global state patterns were found in Stave, a Go CLI for offline security evaluation. The ControlRegistry factory pattern enabled parallel test isolation for 14 security controls. The session extraction fixed a state leak between sequential evaluations. The immutable singletons and caches remain — they're the right tool for process-level state that doesn't change.

Top comments (0)