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
- Local dev — define variables in an uncommitted
.envand load them without overriding the shell (override=False); see .env File Management. - CI — set non-secret variables in the pipeline config; pull secrets from the secret store at job runtime.
- Staging — inject the same keys via the orchestrator (Kubernetes
env/envFrom) so the schema matches production exactly. - 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
SecretStrthe moment they are read so they cannot leak viarepr(). - Keep
override=Falsewhen merging.envso platform-injected values always win. - Validate every typed read; a
ValueErrorat startup beats a corrupt value in production. - Do not pass the full environment to subprocesses — hand them an explicit, minimal
envdict.
Troubleshooting
KeyErrorat 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 toget_boolwith 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
SecretStrand never logos.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.