JavaRush /Courses /Go SELF /Env config in a CLI: precedence and fail fast

Env config in a CLI: precedence and fail fast

Go SELF
Level 50, Lesson 2
Available

1. Environment variables in a CLI: why and how to read them

When you write a CLI, you inevitably face two types of users. The first runs your program manually in a terminal and is fine with specifying flags explicitly. The second runs it in a script, CI, or via cron — and there you want to configure the environment once, rather than pass 10 flags on every run. Environment variables are exactly about this: they let you configure program behavior “from the outside” without changing code and without rewriting the command line.

Environment variables are NAME=VALUE pairs that the operating system passes to your process at startup. In Go, we read them through the os package. It’s important to remember one simple, slightly tricky thing: everything in the environment is a string. Even if you store a “number”, a “boolean”, a “timeout” — in Go you will get a string and must explicitly parse it into the desired type.

To keep this concrete, let’s take our educational project todo and assume we need to configure:

  • the path to the data file (for example, TODO_DATA_FILE);
  • a timeout (for example, TODO_TIMEOUT_SEC), say, for operations that might hang.

We won’t overcomplicate it: the goal of the lecture is not “build a super config”, but to learn how to keep a simple and predictable contract.

os.Getenv vs os.LookupEnv: “unset” vs “set to empty”

Beginners often start with os.Getenv("NAME"). And that’s fine: the function is simple — it returns a string. But it has an unpleasant UX side effect: if the variable is not set, it returns an empty string "". And an empty string can mean something else too: “the variable is set, but empty”. Sometimes it’s important to distinguish these, especially when the config is required.

For this task, Go has a more convenient tool: os.LookupEnv. It returns two values: a string and ok (whether the variable was found).

package main

import (
	"fmt"
	"os"
)

func main() {
	v, ok := os.LookupEnv("TODO_DATA_FILE")
	fmt.Println("value =", v, "ok =", ok) // value = ... ok = true/false
}

The logic is: if ok == false, then the variable doesn’t exist at all. If ok == true, then it exists, and the value can even be empty (for example, someone did TODO_DATA_FILE=). For fail fast, this is a crucial distinction: “unset” can mean “use the default”, while “set to empty” often means “the configuration was broken”.

2. Configuration precedence: flags > env > default

When there are multiple configuration sources, the user naturally asks: “which one wins?”. If the program answers “it depends on the phase of the Moon and the compiler version”, the user won’t find it funny. We need a strict contract:

  • default — the default value when nothing is provided;
  • env — overrides default;
  • flags — override both env and default.

You can capture this in a small table:

Source Example When it is used
Default
./todo.json
If there is neither env nor a flag
Env
TODO_DATA_FILE=/tmp/todo.json
If the flag is not provided
Flags
-data /home/me/todo.json
Always stronger than env

And as a tiny diagram (handy to look at when you’re unsure):

flowchart LR
    D[default] --> E[env]
    E --> F[flags]
    D --> F

The key idea: a flag means “I explicitly want it like this right now”. So if the user specifies -data ..., we have no moral right to say: “but you have env set, so we ignored you”.

“Env as the flag default”: a simple way without magic

There are many ways to merge config. Some quickly turn into a detective story: “was the flag explicitly set?”. In the standard flag package, it’s indeed not super obvious unless you use more advanced techniques.

The simplest and most practical way to enforce the precedence flags > env > default looks like this:

  1. First, read env (if anything is set there).
  2. Compute the “default value for the flag”: either env or a constant default.
  3. Declare the flag with that default.
  4. Parse flags — and they automatically win.

Sounds boring. And boring is good: boring code is easier to maintain.

3. Helpers, validation, and fail fast

If you read env in ten different places, you will inevitably end up with ten different error formats and ten different interpretations of whitespace. So the sane strategy is to write small helper functions and use them consistently everywhere.

envString: a string from env with a default

This helper simply chooses env or default.

package main

import "os"

func envString(name, def string) string {
	if v, ok := os.LookupEnv(name); ok {
		return v
	}
	return def
}

Yes, it’s three lines of logic. But it’s already a “single standard” for the whole project.

envInt: an integer from env with a default and a careful error

Numbers in env are strings. So you need strconv.Atoi.

package main

import (
	"fmt"
	"os"
	"strconv"
)

func envInt(name string, def int) (int, error) {
	s, ok := os.LookupEnv(name)
	if !ok {
		return def, nil
	}
	n, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("%s must be integer, got %q", name, s)
	}
	return n, nil
}

Notice %q: it prints the string with quotes, so the message immediately shows what you got — " 10" (with a leading space), "abc", "10\n", etc. It’s a small thing that dramatically improves a CLI’s “repairability”.

requireNonEmptyEnv: a required variable without ambiguity

Sometimes config must be strictly provided. And here os.LookupEnv is useful again: we can distinguish “unset” from “empty”.

package main

import (
	"fmt"
	"os"
)

func requireNonEmptyEnv(name string) (string, error) {
	v, ok := os.LookupEnv(name)
	if !ok {
		return "", fmt.Errorf("%s is required", name)
	}
	if v == "" {
		return "", fmt.Errorf("%s must not be empty", name)
	}
	return v, nil
}

In our todo, this can be handy, for example, if you decide the data-file path must be explicitly set. But more often for todo it’s nicer to have a reasonable default, so we’ll keep “required” as a technique rather than a mandatory rule.

Fail fast: bad configuration is a startup error

A very common (and very annoying) CLI problem looks like this: the program starts, pretends everything is fine, and then 30 seconds later crashes somewhere deep inside a command because the config was invalid. At that moment the user thinks: “why didn’t you tell me immediately?”.

We set a rule: if the startup configuration is invalid, we exit immediately. That’s fail fast.

It’s important to distinguish this from a “command usage error”. For example, the user runs todo add without a required -title — that’s a usage error for a specific command. But TODO_TIMEOUT_SEC=abc is a problem with the launch environment, unrelated to any specific operation like “add” or “list”. It’s an error of “I can’t even start properly”.

In our CLI conventions this often maps to exit code 2 (usage), because it’s fixed by adjusting how you start the program: change env or pass a flag. But the core idea is not the number; it’s the moment: don’t start executing commands until the config is valid.

A whitespace nuance: TrimSpace can sometimes save your nerves

In an ideal world, env is set neatly: TODO_TIMEOUT_SEC=5. In the real world, anything can end up there, especially if the variable comes from a .env file, CI secrets, or copy/paste. Sometimes it can include spaces: " 5" or "5 ".

You can be strict and treat it as an error. Or you can make UX a bit friendlier and trim whitespace. For numbers, this is often justified because the meaning doesn’t change.

Here is how you can improve envInt (still short):

package main

import (
	"fmt"
	"os"
	"strconv"
	"strings"
)

func envInt(name string, def int) (int, error) {
	s, ok := os.LookupEnv(name)
	if !ok {
		return def, nil
	}
	s = strings.TrimSpace(s)
	n, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("%s must be integer, got %q", name, s)
	}
	return n, nil
}

This is optional, but nice: you reduce the number of “silly crashes” due to an extra space. At the same time, got %q will still show what came in.

4. Wiring env config into todo

Now let’s put it all together into a small but realistic snippet. Assume we have global application settings: a data file and a timeout.

Let’s make a Config struct. This is not “architecture for architecture’s sake”, just a way to keep settings in one place so we don’t drag two variables across the whole codebase.

package main

type Config struct {
	DataFile   string
	TimeoutSec int
}

Read env and validate

Now a function that reads env and builds defaults:

package main

import "fmt"

func loadEnvConfig() (Config, error) {
	timeout, err := envInt("TODO_TIMEOUT_SEC", 5)
	if err != nil {
		return Config{}, err
	}
	dataFile := envString("TODO_DATA_FILE", "./todo.json")
	if dataFile == "" {
		return Config{}, fmt.Errorf("TODO_DATA_FILE must not be empty")
	}
	return Config{DataFile: dataFile, TimeoutSec: timeout}, nil
}

Here we use our helper envInt, and we get the string via envString. We forbid the empty string because “file path = empty” is almost always an error, not an “intentional setting”.

Make flags with env-based defaults

Now in run (or in main if you don’t have run yet) we do it like this: env first, then flags.

package main

import (
	"flag"
	"fmt"
	"os"
)

const exitUsage = 2

func main() {
	cfg, err := loadEnvConfig()
	if err != nil {
		fmt.Fprintln(os.Stderr, err.Error())
		os.Exit(exitUsage)
	}

	data := flag.String("data", cfg.DataFile, "data file (env TODO_DATA_FILE)")
	timeout := flag.Int("timeout", cfg.TimeoutSec, "timeout seconds (env TODO_TIMEOUT_SEC)")
	flag.Parse()

	_ = data
	_ = timeout
}

This is exactly the “env as the flag default” trick. If the user specifies -timeout 10, then 10 wins. If they don’t — the value from env remains (if it was set), otherwise our default 5.

And yes, we explicitly mention (env TODO_...) in the help text. This is not “chatter”; it’s part of UX: the user should see an alternative configuration mechanism, otherwise they’ll never guess it exists.

Where exactly to read env: once at startup

It’s very tempting to do this: in the add command, read TODO_DATA_FILE; in the list command, read TODO_TIMEOUT_SEC; and in the done command, read something else. The result is “config puree”: some settings apply, some don’t, and errors pop up in different places.

It’s much calmer (for both the user and the developer) to follow this rule: env is read once, at startup, and turned into a configuration struct. After that, commands receive ready values (via function parameters or struct fields). This makes behavior stable: any run either starts correctly or fails immediately with a clear reason.

Put simply: configuration is “process launch conditions”, not “an internal data request during runtime”. So it makes sense for it to live at the application boundary, near main.

Documenting env vars: the app should be honest

When you write a small todo, documenting env can feel “too serious”. But in practice, lack of documentation is exactly what makes a program “magical”: it seems to work, but nobody understands how.

At our scale, two things are enough.

First, mention env next to the flag in Usage/PrintDefaults, as we already did: "timeout seconds (env TODO_TIMEOUT_SEC)".

Second, if you have custom help, you can add a short env block. For example, for the root command:

package main

import (
	"flag"
	"fmt"
	"os"
)

func setupUsage() {
	flag.Usage = func() {
		fmt.Fprintln(os.Stderr, "Usage:")
		fmt.Fprintln(os.Stderr, "  todo <cmd> [flags] [args]")
		fmt.Fprintln(os.Stderr, "")
		fmt.Fprintln(os.Stderr, "Flags:")
		flag.PrintDefaults()
		fmt.Fprintln(os.Stderr, "")
		fmt.Fprintln(os.Stderr, "Environment:")
		fmt.Fprintln(os.Stderr, "  TODO_DATA_FILE, TODO_TIMEOUT_SEC")
	}
}

Notice: we’re not writing a novel “about the meaning of environment variables”. We simply and honestly show that such a mechanism exists.

5. Common mistakes when working with env config

Mistake No. 1: using os.Getenv where it matters to distinguish “missing” from “empty”.
If you read a required setting via Getenv, you won’t be able to tell whether the variable was forgotten or set to an empty string. As a result, diagnostics become muddy: you see "" and don’t know whether it’s “by design” or “the config was broken”. In such places, it’s better to use os.LookupEnv right away.

Mistake No. 2: breaking precedence — env unexpectedly beats a flag.
Sometimes people do this: parse flags first, then “mix in env”, and as a result a flag the user explicitly provided gets ignored. This is one of the most unpleasant UX bugs, because it looks like “the program isn’t listening to me”. The simplest way to avoid this is to use env as the default when declaring the flag.

Mistake No. 3: env parsing errors are ignored, and the program keeps running with garbage.
If TODO_TIMEOUT_SEC=abc, and you silently turn it into 0 or fall back to default without warning, the user will later get weird behavior and won’t understand why. Config should be strict: if a value is set but invalid, it’s a startup error, and it’s better to exit immediately.

Mistake No. 4: env is read “where needed” in different commands, and behavior becomes unpredictable.
When each subcommand decides on its own what and how to read from the environment, you get a zoo: different defaults, different error formats, different interpretations of empty values. It’s far more stable to read env once at startup, validate, build a Config, and then pass it into commands as ordinary values.

Mistake No. 5: env exists, but the user never learns about it.
Technically everything works, but if help never mentions TODO_TIMEOUT_SEC, then it’s a “hidden feature”, and hidden features are loved only by authors — users hate them (they’re not telepaths). A small note in PrintDefaults() and, if desired, a single line in the Environment section of your help is enough.

1
Task
Go SELF, level 50, lesson 2
Locked
Configuration check
Configuration check
1
Task
Go SELF, level 50, lesson 2
Locked
Worker pool
Worker pool
1
Task
Go SELF, level 50, lesson 2
Locked
Priority of settings
Priority of settings
1
Task
Go SELF, level 50, lesson 2
Locked
Token-based access
Token-based access
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION