Skip to content

← The Edge of the System step 2 of 12

Medium End-to-End

Typed settings: parse the environment once, report every problem

Configuration read lazily and untyped fails at 03:00 on the one code path nobody exercised, with a KeyError and no indication which of forty variables is missing. The fix is structural: parse the whole environment exactly once, at startup, into a typed immutable object, and fail loudly with everything that is wrong.

def load_settings(env: Mapping[str, str]) -> Settings: ...

Note the parameter. load_settings() reaching into os.environ is untestable and picks up your laptop’s environment in CI; the injected mapping is the whole difference between a function you can test and one you cannot.

Settings is given:

field env var type default
service_name SERVICE_NAME str required
port PORT int 8080
debug DEBUG bool False
environment ENVIRONMENT Literal["dev", "staging", "prod"] "dev"
timeout_seconds TIMEOUT_SECONDS float 5.0

Why the type checker forces you to do this properly

--strict rejects Settings(port=env["PORT"]) outright, because str is not int. That is the point. Every environment variable is a string; the checker will not let you pretend otherwise, so the coercion becomes an explicit, reviewable, testable step instead of an implicit one that fails in production.

The bool trap

debug = bool(os.environ.get("DEBUG"))   # DEBUG=false  ->  True

Every non-empty string is truthy. "false", "0", "no" — all True. This is one of the most common bugs in Python configuration and it silently ships debug mode to production.

Accept, case-insensitively and after stripping: 1 true yes on as true, 0 false no off as false. Reject anything else"maybe" is an error, not a guess. Guessing is how a typo becomes a silent behaviour change.

Report every problem, not the first one

If PORT and TIMEOUT_SECONDS are both wrong, an operator must learn that in one deploy, not two. Collect FieldErrors in a list and raise a single ExceptionGroup("invalid settings", errors) at the end.

Each FieldError must chain the underlying ValueError:

try:
    return int(raw)
except ValueError as exc:
    error = FieldError(name, "expected a base-10 integer")
    error.__cause__ = exc          # exactly what `raise ... from exc` does
    errors.append(error)

raise X from exc is sugar for setting __cause__ and raising. When you are collecting rather than raising, set it yourself — otherwise the traceback loses the only line that says what actually failed to parse. A missing required variable has no underlying exception, so its __cause__ stays None; the driver reports "NoneType" for that case.

Narrowing to a Literal without cast

ENVIRONMENT arrives as a str and the field is Literal["dev", "staging", "prod"]. cast would work and would also lie — it asserts something you have not checked. The honest version:

for candidate in _ENVIRONMENTS:      # tuple[Environment, ...]
    if lowered == candidate:
        return candidate             # already has the narrow type
raise ValueError(...)

One more --strict trap waits for you: the default value must carry the narrow annotation too. An unannotated "dev" widens the inferred type back to str and the Settings(...) call fails to type-check.

Unknown variables are NOT an error

Unlike a config file — where an unrecognised key means a typo (see “Parse, don’t validate”) — the environment is a shared namespace containing PATH, HOME and a hundred other things. Ignore what you do not recognise. The asymmetry is deliberate and worth remembering.

The part this problem cannot test, and you still have to know

Secrets do not belong in the repo, the image, a log line or an exception message. Prefer file-mounted or broker-issued credentials over environment variables: an env var is visible in /proc/<pid>/environ, is inherited by every child process you spawn, and lands in crash dumps. pydantic-settings supports exactly this with secrets_dir (with the environment taking priority), and SecretStr keeps the value out of reprs. If you stay dependency-free, a small frozen wrapper with a redacting __repr__ does the same job in eight lines.

Three defensible stacks, and the real trade-off between them:

  • frozen dataclass + hand-written from_env — zero dependencies, fully explicit, every coercion visible in review. What you are writing here.
  • pydantic-settingsBaseSettings, .env files, secrets_dir, SecretStr, layered precedence for free; a hard dependency and a second validation model in your process.
  • environ-config / attrs — lighter than pydantic, declarative, and the library is already there if you use attrs elsewhere.

Any of the three is fine. Reading forty os.environ.get calls scattered through the codebase is not.

Loading visualization…