In Go, how do you let an environment variable set a flag's default so an explicit flag still wins?
answer
- the flag package never reads the environment
- a default is just an argument
- lookup happens before Parse, not after
- seed the default, let Parse override it
- LookupEnv's second result means present
basics
~20 sRead the variable with os.LookupEnv before flag.Parse and pass its value as the default argument when you declare the flag. flag.Parse then overwrites that default only for flags that actually appeared on the command line.
solid answer
~50 sGo's `flag` package knows nothing about the environment, so the precedence order is code you write. The lever is that a flag's default is an ordinary argument: resolve it first, declare the flag with it, then call `flag.Parse`. For each setting I start from a compiled-in default, overwrite it when `os.LookupEnv` reports the variable is present, parse it into the right type with `time.ParseDuration` or `strconv.Atoi`, and pass the result as the default to `flag.Duration` or `flag.IntVar`. `flag.Parse` assigns only for flags that actually appeared on the command line, so the resulting order is flag beats environment beats built-in default, with no extra bookkeeping. Two details matter: do the lookup before `Parse`, never after, or you clobber what the operator typed; and decide explicitly what a present-but-empty variable means, because `os.LookupEnv` reports it as set.
code
go · 12 lines// order: built-in default < environment variable < command-line flag
func durationFlag(name, env string, def time.Duration, usage string) (*time.Duration, error) {
if raw, ok := os.LookupEnv(env); ok {
d, err := time.ParseDuration(raw)
if err != nil {
return nil, fmt.Errorf("%s=%q: %w", env, raw, err)
}
def = d
}
// flag.Parse will overwrite this only if -name is on the command line
return flag.Duration(name, def, usage), nil
}go deeper
Be ready to write the four lines from memory: look the variable up, convert it, pass it as the flag's default, then call flag.Parse once after all flags are declared.
Explain why seeding the default gives you precedence for free, and what flag.Parse actually does when a flag is absent from the command line. Know that a present-but-empty variable still reports as set.
Show how you keep this honest in production: one resolution function, a table-driven test over the precedence cases, and a startup log of the effective values with secrets redacted so a new environment can be debugged in one look.
Own the convention rather than the code. Pick one resolution order, implement it once for every service, and require that the effective config is visible at startup; fleet-wide consistency matters more than which order you choose.
## The problem A service usually needs the same setting to be reachable three ways: a value compiled into the binary so a developer can just run it, an environment variable so a deployment platform can inject it, and a command-line flag so a human can override it for one run. The conventional resolution order is **flag beats environment beats built-in default** — most specific and most deliberate wins. Go's standard library gives you no config framework for this. The `flag` package parses the command line and nothing else; `os.LookupEnv` reads one environment variable and nothing else. The layering is a dozen lines you write in `main`. ## The mechanism Every flag constructor takes the default as an argument: ```go flag.Duration(name string, value time.Duration, usage string) *time.Duration flag.DurationVar(p *time.Duration, name string, value time.Duration, usage string) ``` That `value` argument is evaluated by ordinary Go code before `flag.Parse` ever runs. So instead of a constant, pass the environment-resolved value: 1. Start with the built-in default. 2. Call `os.LookupEnv("MYSVC_REQUEST_TIMEOUT")`. Its second result is a boolean reporting whether the variable is **present**, which is the whole reason to prefer it over `os.Getenv` here — you need present-with-empty-value to be distinguishable from absent. 3. If present, convert the string to the field's real type: `time.ParseDuration` for durations, `strconv.Atoi` or `strconv.ParseInt` for integers, `strconv.ParseFloat` for percentages, `strconv.ParseBool` for switches. Check the error; a bad value is a startup failure, not something to shrug off. 4. Declare the flag with that resolved value as its default. 5. Call `flag.Parse` once, after every flag is declared. `flag.Parse` walks the command-line arguments and calls `Set` only for flags that actually appear there. A flag the operator did not type keeps whatever default you seeded. That is the entire precedence engine. ## Why not read the environment after Parse The tempting shortcut is to call `flag.Parse` first and then apply the environment on top: ```go flag.Parse() if v, ok := os.LookupEnv("MYSVC_ADDR"); ok { addr = v // wrong: this beats the flag the operator typed } ``` Now the environment always wins and the flag is decorative — precisely backwards from what operators expect, and maddening to debug at 3am when `-addr` visibly does nothing. You could repair it by asking the `flag` package which flags were explicitly set, but seeding the default keeps a single resolution path and needs no such check. ## The empty-string decision `os.LookupEnv` returns `("", true)` for `MYSVC_ADDR=`. Deployment systems produce that constantly: a template renders an unset value, a shell exports a variable it never filled in. You must pick a rule and document it. For optional strings, treating empty as "not set" (fall through to the next layer) is usually kindest. For a typed setting, empty is a malformed value and should fail startup — an empty string is not a duration, and silently substituting zero is how a service ends up with a timeout of `0`. ## Naming and discoverability Prefix the variables with the service name (`FEATUREFLAGS_REQUEST_TIMEOUT`, not `TIMEOUT`) so they cannot collide with something the platform already sets, and derive the name mechanically from the flag name so a reader can guess one from the other. Mention the variable in the flag's usage string; `flag` prints it in `-h` output, and that is the only documentation some operators will find. ## Proving it works Precedence is exactly the kind of logic that looks obviously correct and is quietly wrong, so it is worth a table-driven test: rows for default-only, environment-only, flag-only, and environment-plus-flag, each asserting the resolved configuration struct. Write the resolution as a function taking the argument slice and an environment lookup rather than reading globals, and the test needs no process-level trickery. Finally, log the resolved configuration once at startup with secrets redacted. The engineer bringing the service up in a new environment for the first time then sees which value each setting ended up with, instead of guessing which of the three layers won.
- Why must the environment lookup happen before flag.Parse rather than after it?After `Parse` the variable already holds either the operator's value or the compiled default, and nothing distinguishes them without extra bookkeeping. Applying the environment there overwrites an explicit flag, inverting the expected precedence. Seeding the default first keeps one resolution path: `Parse` assigns only for flags actually present on the command line.
- The deployment sets the variable to an empty string. Does that count as set?`os.LookupEnv` says yes — it returns `("", true)` — so you must decide. For an optional string, treating empty as unset and falling through to the next layer is usually right. For a typed setting it should fail startup, since an empty string is not a valid duration or integer and quietly turning it into zero is a real outage.
- How would you test that the precedence is actually flag over environment over default?Make resolution a function that takes the argument slice and an environment lookup instead of reading globals, then write a table-driven test with one row per case: nothing set, environment only, flag only, and both set with different values. Each row asserts the whole resolved config struct, so a future reordering of the layers fails loudly.
The environment fills the form in pencil before anyone looks at it; the command line writes over it in ink.
saying these in an interview costs you the question
- Applies the environment variable after flag.Parse, so the flag never wins
- Believes the flag package reads environment variables by itself
- Uses os.Getenv and cannot tell an absent variable from an empty one
- Declares flags after calling flag.Parse and wonders why nothing is set