Environment Variables & os.environ

The classic production failure is a service that boots with DEBUG=False set in the environment yet runs in debug mode anyway, because bool("False") is True. Environment variables are the 12-factor baseline for configuration, but os.environ hands you raw strings with no typing and no safety net. This page is the disciplined way to read them.

This technique sits at the very front of the configuration pipeline: environment variables are the first structured source the process sees, and everything downstream — precedence resolution and pydantic-settings validation — depends on reading them correctly.

Secure implementation

# config/env.py
import os

def get_bool(key: str, default: bool = False) -> bool:
    raw = os.environ.get(key)
    if raw is None:
        return default
    return raw.strip().lower() in {"1", "true", "yes", "on"}   # explicit truthy set

def get_int(key: str, default: int | None = None) -> int:
    raw = os.environ.get(key)
    if raw is None:
        if default is None:
            raise SystemExit(f"Missing required integer env var: {key}")
        return default
    try:
        return int(raw)
    except ValueError as exc:
        raise SystemExit(f"{key} must be an integer, got {raw!r}") from exc

def get_list(key: str, sep: str = ",") -> list[str]:
    raw = os.environ.get(key, "")
    return [item.strip() for item in raw.split(sep) if item.strip()]

DEBUG = get_bool("DEBUG")             # "False" correctly becomes False
WORKERS = get_int("WORKERS", 4)
ALLOWED_HOSTS = get_list("ALLOWED_HOSTS")

Each accessor fails fast and loudly. A misconfigured integer never silently becomes a default; a missing required value stops the process at import time rather than during the first request.

Configuration reference

Pattern Type Default behaviour Security implication
os.environ["KEY"] str Raises KeyError if unset Fail-fast; preferred for required values
os.getenv("KEY", d) str | None Returns d if unset A wrong default can silently ship dev config
get_bool bool False unless explicitly truthy Prevents "False" truthiness bug
SecretStr(value) masked Hidden in repr/logs Keeps secrets out of tracebacks
override=False n/a Platform env wins over .env Stops local files clobbering injected secrets

Deployment parity: local to production

  1. Local dev — define variables in an uncommitted .env and load them without overriding the shell (override=False); see .env File Management.
  2. CI — set non-secret variables in the pipeline config; pull secrets from the secret store at job runtime.
  3. Staging — inject the same keys via the orchestrator (Kubernetes env/envFrom) so the schema matches production exactly.
  4. Production — identical key names, secrets sourced from a managed store, validated by the settings model on boot.

Security boundaries & guardrails

  • Never write a secret-bearing environment variable into a committed manifest or Dockerfile.
  • Wrap secret values in SecretStr the moment they are read so they cannot leak via repr().
  • Keep override=False when merging .env so platform-injected values always win.
  • Validate every typed read; a ValueError at startup beats a corrupt value in production.
  • Do not pass the full environment to subprocesses — hand them an explicit, minimal env dict.

Troubleshooting

  • KeyError at startup — a required variable is unset. This is the system working: set the key or move it to an optional accessor with a safe default.
  • Boolean always true — you are using bool(os.environ["FLAG"]); switch to get_bool with an explicit truthy set.
  • ValueError: invalid literal for int() — a numeric variable contains whitespace or a unit suffix; strip and validate in the accessor.
  • Secret appears in logs — something logged the raw environment or a config object; wrap secrets in SecretStr and never log os.environ.

Frequently asked questions

Should I use os.getenv or os.environ to read configuration?

Use os.environ["KEY"] for required values so a missing key raises KeyError immediately at startup. Reserve os.getenv("KEY", default) for genuinely optional values where the default is safe in production.

Why does my boolean environment variable always evaluate to True?

Environment variables are always strings, and the string "False" is truthy in Python. Parse booleans explicitly — only treat "1", "true", "yes", and "on" (case-insensitive) as True.

Should secrets be passed as environment variables?

Short-lived, injected-at-runtime secrets in the process environment are acceptable, but never bake secrets into manifests or images. Wrap them in SecretStr once read and prefer a managed secret store for anything long-lived.

Conclusion

The invariant this page enforces: no value leaves os.environ untyped and no required key is allowed to default silently. Type every read, fail fast on the rest, and feed the results into a validated settings model.