Environment Variables & Configuration
1 · The lesson
readThe single fastest way to leak a production database password to GitHub is to hardcode it in settings.py, commit, push, and forget. The second-fastest is .env accidentally committed. Both are recoverable, both are common, both are avoidable with five minutes of structure.
This lesson covers how professional Python apps load configuration: from environment variables, with .env for local dev, typed and validated at app start, never logged. It's the 12-Factor App approach — separate config from code, treat your app the same in dev, staging, and prod, swap behaviour by changing env vars.
1. Why Config-as-Code Is a Trap
The temptation:
# settings.py — checked into git DATABASE_URL = "postgres://prod-user:hunter2@db.example.com/app" API_KEY = "sk_live_AbCdEf1234567890" DEBUG = False
Every problem with this file shows up the day after you deploy:
- Secrets in git history — even if you delete the file, the password is in the history forever. Rotate every credential and force-push, or accept that the secret is compromised.
- No per-environment override — dev needs a different DB than prod, but the file says one thing.
- Hard to rotate — changing the password means a code change, PR, review, deploy. Hostile to ops.
- Forks and leaks — anyone who clones the repo, including ex-employees, has your prod keys.
The fix is one rule: config lives in the environment, code lives in git. The environment is per-machine, per-deployment, per-developer; the code is the same everywhere.
2. The 12-Factor Principle
From the 12-Factor App methodology — the canonical principles for cloud-deployable services:
Config: Store config in the environment. An app's config is everything that's likely to vary between deploys (staging, production, dev). Apps sometimes store config as constants in the code. This is a violation of twelve-factor, which requires strict separation of config from code.
In practice: anything that varies between environments is config and goes in env vars. Code paths, credentials, hostnames, feature flags, log levels — none of that belongs in the repo.
What stays in the repo: business logic, schemas, the defaults for safe-to-publish values (port 8000, debug false).
3. Reading from os.environ
The stdlib API is os.environ, a dict-like object backed by the process environment:
import os # DANGER — KeyError if the variable isn't set api_key = os.environ["API_KEY"] # SAFE — returns None (or your default) when unset api_key = os.environ.get("API_KEY") debug = os.environ.get("DEBUG", "false")
setup added so this can run · defines
import os # noqa: F401 os.environ.setdefault("API_KEY", "example-api-key") os.environ.setdefault("DEBUG", "false")
The pattern:
os.environ["X"]when the variable is required — theKeyErroris the right behaviour because the app should refuse to start without it.os.environ.get("X", default)when there's a sensible default.
Wrap required vars in a clear error message at startup:
def required_env(name): try: return os.environ[name] except KeyError: raise SystemExit(f"missing required environment variable: {name}")
setup added so this can run · defines os
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) os = _AutoMock('os')
SystemExit exits cleanly with no traceback noise — KeyError traces are confusing in deployment logs. Failing fast at startup is much better than failing on the first request.
4. .env Files — Local Development
You don't want to type API_KEY=... DATABASE_URL=... python app.py every time. The standard local-dev pattern is a .env file:
# .env (never commit this) DATABASE_URL=postgres://localhost/app_dev API_KEY=sk_test_local_dev_key DEBUG=true PORT=8000
Load it with python-dotenv:
pip install python-dotenv
from dotenv import load_dotenv load_dotenv() # reads .env into os.environ import os print(os.environ["DATABASE_URL"]) # available everywhere
setup added so this can run · defines
import os # noqa: F401 os.environ.setdefault("DATABASE_URL", "postgresql://user:password@localhost:5432/example")
Call load_dotenv() once, at the top of your entry point, before any code that reads os.environ. It's a no-op in production (where the env vars are set by the deployment platform — Heroku, Kubernetes, systemd, Docker).
Critical: add .env to .gitignore before you create the file. Then commit a .env.example with placeholders:
# .env.example (commit this) DATABASE_URL=postgres://user:pass@host/db API_KEY=your-key-here DEBUG=false PORT=8000
New contributors copy .env.example to .env and fill in their own values. Everyone has working local config; nothing secret reaches git.
5. Typed Config — Coercing Strings
Environment variables are always strings. os.environ["PORT"] is "8000", not 8000. Convert at the boundary:
port = int(os.getenv("PORT", "8000")) ratio = float(os.getenv("SAMPLING_RATIO", "0.1"))
setup added so this can run · defines os
import os # noqa: F401 os.environ.setdefault("PORT", "8000") os.environ.setdefault("SAMPLING_RATIO", "example-sampling-ratio") # Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) os = _AutoMock('os')
Booleans are a trap. Python's bool("false") is True — non-empty strings are truthy. The stdlib has no env-var bool helper, so write one:
def env_bool(name, default=False): raw = os.getenv(name) if raw is None: return default return raw.strip().lower() in ("1", "true", "yes", "on") DEBUG = env_bool("DEBUG") # True only for explicit truthy values
setup added so this can run · defines os
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) os = _AutoMock('os')
Accept 1/true/yes/on as truthy; everything else (0/false/no/off/"") as falsy. Pick a list, document it, use it consistently.
For comma-separated lists:
def env_list(name, default=()): raw = os.getenv(name) return tuple(item.strip() for item in raw.split(",")) if raw else tuple(default) ALLOWED_HOSTS = env_list("ALLOWED_HOSTS", ["localhost"])
setup added so this can run · defines os
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) os = _AutoMock('os')
6. pydantic-settings — Typed Config in 10 Lines
For larger apps, hand-rolling coercion gets old. pydantic-settings (the spiritual successor to Pydantic's old BaseSettings) loads env vars into a typed model with validation:
pip install pydantic-settings
from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") database_url: str api_key: str debug: bool = False port: int = 8000 allowed_hosts: list[str] = ["localhost"] settings = Settings() # reads env + .env, validates, coerces print(settings.port) # int, not str print(settings.debug) # real bool
You get:
- Type coercion —
"8000"becomes8000,"true"becomesTrue. List parsing for free. - Validation — a malformed
portraises a clearValidationErrorat startup. - Required vs optional — fields without defaults are required. Missing ones fail fast.
- Per-env files —
model_config = SettingsConfigDict(env_file=".env.prod")swaps the source.
This is the production-grade option. Use it the moment your config has more than ~5 fields.
7. Layered Config — The Precedence Hierarchy
Real apps load config from several sources, with a strict precedence:
CLI args > environment variables > config file > built-in defaults (last wins) (first applied)
CLI flags override env vars (so you can do a one-off run with --debug). Env vars override config files (so production overrides dev defaults). Config files override built-in constants (so deployers can tweak without code changes).
collections.ChainMap is one clean way to express this — see collections:
from collections import ChainMap defaults = {"port": 8000, "debug": False} file_config = load_yaml("config.yaml") # might be {} env_config = {k: v for k, v in os.environ.items() if k.startswith("APP_")} cli_config = vars(args) # from argparse config = ChainMap(cli_config, env_config, file_config, defaults)
setup added so this can run · defines load_yaml, args, os
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def load_yaml(*_a, **_kw): print('-> load_yaml() called') return _AutoMock('load_yaml()') args = _AutoMock('args') os = _AutoMock('os')
ChainMap looks up keys left-to-right, so the leftmost source wins. pydantic-settings handles most of this for you, but understanding the principle helps when you're deciding what should override what.
8. Secrets — Don't Log Them, Don't Leak Them
Two rules:
1. Mask secrets in logs and reprs.
def mask_secret(value, show=4): """Show only the last `show` chars: 'sk_live_AbCdEf12' -> '****Ef12'.""" if not value or len(value) <= show: return "****" return "*" * (len(value) - show) + value[-show:] print(f"loaded API_KEY={mask_secret(settings.api_key)}") # loaded API_KEY=********7890
setup added so this can run · defines settings
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) settings = _AutoMock('settings')
When dataclass-style configs print themselves in a traceback, the secret would otherwise show up in the log. With dataclasses you can field(repr=False) — see dataclasses — or with Pydantic use SecretStr which prints as '**********'.
2. Never log the whole env.
# CATASTROPHIC — dumps every secret into the log logger.debug(f"env: {dict(os.environ)}")
setup added so this can run · defines logger, os
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) logger = _AutoMock('logger') os = _AutoMock('os')
Production logs get shipped to a dozen third-party services. One print(os.environ) for debugging, left in by accident, leaks every credential. Log specific keys, never the whole dict.
For production secrets management, the real tools are:
| Tool | Where it fits |
|---|---|
| AWS Secrets Manager | AWS-native apps; rotation built in |
| HashiCorp Vault | Self-hosted, multi-cloud, fine-grained policies |
| GCP Secret Manager | GCP-native equivalent |
| Azure Key Vault | Azure equivalent |
| doppler.com / 1Password Secrets | Hosted developer-friendly options |
All of them expose secrets via either env vars (injected at boot) or an API. From your Python code's perspective, the read site is still os.environ.get("API_KEY") — the tooling fills the environment before your process starts.
Local dev: .env plus optionally direnv, which auto-loads a directory's env on cd.
9. Paths and "Where Am I?"
Two Path patterns come up constantly in config:
from pathlib import Path # Files relative to THIS script (e.g. a default config bundled with the code) HERE = Path(__file__).resolve().parent DEFAULT_CONFIG = HERE / "defaults.yaml" # Files relative to where the USER invoked the script (e.g. local .env) cwd_env = Path.cwd() / ".env"
Path(__file__).parent is the directory of the source file — stable, independent of where the user cd'd. Use it for bundled resources.
Path.cwd() is the current working directory — depends on where the user ran the command. Use it when you genuinely want "the user's working directory."
Hardcoded /Users/surya/... or C:\Users\... paths are immediate portability bombs. Use one of the two patterns above plus env vars for anything user-configurable.
10. Per-Environment Switching
The common pattern: an APP_ENV variable that selects a config flavour.
APP_ENV = os.getenv("APP_ENV", "development") if APP_ENV == "production": DEBUG = False LOG_LEVEL = "WARNING" elif APP_ENV == "staging": DEBUG = False LOG_LEVEL = "INFO" else: # development DEBUG = True LOG_LEVEL = "DEBUG"
setup added so this can run · defines os
import os # noqa: F401 os.environ.setdefault("APP_ENV", "example-app-env") # Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) os = _AutoMock('os')
Or with pydantic-settings, load a different .env file per environment:
env_name = os.getenv("APP_ENV", "development") settings = Settings(_env_file=f".env.{env_name}")
setup added so this can run · defines Settings, os
import os # noqa: F401 os.environ.setdefault("APP_ENV", "example-app-env") # Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def Settings(*_a, **_kw): print('-> Settings() called') return _AutoMock('Settings()') os = _AutoMock('os')
The values that vary across environments are still in env vars; APP_ENV is just the selector that picks which flavour. Many platforms (Render, Fly, AWS) inject env vars per environment and you don't even need APP_ENV — but having one explicit knob is useful for debugging "which config am I running?"
11. Common Mistakes
1. Committing .env to git. The cardinal sin. Add .env to .gitignore before you ever create the file. Then commit .env.example with placeholder values. For belt-and-braces, install a pre-commit hook like git-secrets or gitleaks to scan for accidentally-staged credentials.
2. os.environ["X"] for optional variables. Hard KeyError on missing env vars is fine only if the variable is genuinely required. For optional config use .get("X", default). Match the strictness to the requirement.
3. bool("false") is True.
DEBUG = bool(os.getenv("DEBUG", "false")) # WRONG — always True
setup added so this can run · defines os
import os # noqa: F401 os.environ.setdefault("DEBUG", "false") # Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) os = _AutoMock('os')
Non-empty strings are truthy. Parse explicitly with the env_bool helper from Section 5.
4. Hardcoded paths. "/Users/surya/projects/app/data" works for exactly one person on exactly one machine. Use Path(__file__).parent for bundled files and env vars for user-configurable ones.
5. Loading config inside hot functions.
def handle_request(): api_key = os.environ["API_KEY"] # read on EVERY request ...
setup added so this can run · defines os
import os # noqa: F401 os.environ.setdefault("API_KEY", "example-api-key") # Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) os = _AutoMock('os')
Read once at startup into a module-level Settings object, then access settings.api_key everywhere. Re-reading wastes time and means a config change can't take effect without a restart anyway.
6. print(os.environ) for debugging. One stray print(dict(os.environ)) left in code and shipped to production leaks every secret to your logging service. Print specific values, never the whole environment.
7. No defaults for safe values. port, log_level, debug should all have built-in defaults so the app boots on a fresh machine with just the required secrets set. Don't make PORT=8000 mandatory.
🎯 Your Turn — A Typed Config Dataclass
Build a Config dataclass that loads from os.environ with type coercion and a clear error when a required variable is missing.
Requirements:
1. Fields: db_url: str (required), api_key: str (required), port: int = 8000, debug: bool = False.
2. A from_env() classmethod that:
- Reads each field from os.environ (var names: DB_URL, API_KEY, PORT, DEBUG).
- Coerces port to int and debug to bool using the truthy-string rule from Section 5.
- Raises a SystemExit with a clear message naming all missing required variables (not just the first one).
3. A __repr__ (or field(repr=False) on api_key) that doesn't leak the API key.
Skeleton:
import os from dataclasses import dataclass, field @dataclass class Config: db_url: str api_key: str = field(repr=False) port: int = 8000 debug: bool = False @classmethod def from_env(cls): # TODO 1: gather required values; collect missing names into a list # TODO 2: if missing is non-empty, raise SystemExit listing them # TODO 3: coerce port and debug; return cls(...) ... cfg = Config.from_env() print(cfg)
Hint 1 — Collecting all missing, not just the first
Build a listmissing = [], do db_url = os.environ.get("DB_URL") and append "DB_URL" to missing if it's None. Same for API_KEY. Then check if missing: raise SystemExit(...). Listing them all at once saves the user a round trip through "fix one, see the next."
Hint 2 — Boolean coercion
raw.lower() in ("1", "true", "yes", "on"). Handle the case where the var isn't set at all by falling through to the field's default.
Show full solution
import os from dataclasses import dataclass, field def _env_bool(name, default=False): raw = os.getenv(name) if raw is None: return default return raw.strip().lower() in ("1", "true", "yes", "on") @dataclass class Config: db_url: str api_key: str = field(repr=False) # don't print secrets port: int = 8000 debug: bool = False @classmethod def from_env(cls): missing = [] db_url = os.environ.get("DB_URL") if db_url is None: missing.append("DB_URL") api_key = os.environ.get("API_KEY") if api_key is None: missing.append("API_KEY") if missing: joined = ", ".join(missing) raise SystemExit( f"missing required environment variable(s): {joined}\n" f"set them in your shell or in a .env file before running." ) # Type coercion with sensible fallbacks try: port = int(os.environ.get("PORT", "8000")) except ValueError: raise SystemExit(f"PORT must be an integer, got {os.environ['PORT']!r}") debug = _env_bool("DEBUG", default=False) return cls(db_url=db_url, api_key=api_key, port=port, debug=debug) # Demo: simulate a partial environment os.environ["DB_URL"] = "postgres://localhost/app" os.environ["API_KEY"] = "sk_test_abc123xyz" os.environ["PORT"] = "9090" os.environ["DEBUG"] = "true" cfg = Config.from_env() print(cfg) # Config(db_url='postgres://localhost/app', port=9090, debug=True) # Note: api_key is hidden because field(repr=False) print(f"connecting to {cfg.db_url} on port {cfg.port} (debug={cfg.debug})")
setup added so this can run · defines
import os # noqa: F401 os.environ.setdefault("PORT", "8000") os.environ.setdefault("DB_URL", "postgresql://user:password@localhost:5432/example") os.environ.setdefault("API_KEY", "example-api-key") os.environ.setdefault("DEBUG", "false")
What you built:
- Required vs optional done right — required vars raise a clear error, optional ones have defaults.
- Aggregated missing-var reporting — if both
DB_URLandAPI_KEYare missing, the user sees both in one message instead of fixing one and re-running to discover the next. - Real type coercion —
PORTbecomes anint,DEBUGbecomes a properbool(notbool("false") == True). field(repr=False)onapi_key— printing the config object doesn't leak the secret to logs.SystemExitfor missing config — clean exit, no traceback noise. The exit code propagates so deployment scripts can detect "config invalid."
The whole pattern is ~30 lines and replaces a settings.py with hardcoded values. As your app grows past ~5 fields, swap this for pydantic-settings — it does all of this plus validation, but the principle is identical.
What You Learned
- Config-as-code is a security incident in waiting. Anything that varies between environments goes in env vars.
os.environ.get(name, default)for optional config.os.environ[name](or arequired_envhelper) for required config that should fail fast..envfiles for local dev withpython-dotenv. Gitignore.env; commit.env.example.- Env vars are always strings — coerce with
int(),float(), customenv_bool.bool("false") is Trueis the canonical gotcha. pydantic-settingsfor typed, validated config in larger apps — type coercion and field validation for free.- Layered config: CLI > env > file > defaults.
ChainMapexpresses this cleanly — see collections. - Never log secrets.
field(repr=False)on dataclass secrets,SecretStrin Pydantic, amask_secrethelper for logs. Path(__file__).parentfor bundled files,Path.cwd()for the user's working directory. Never hardcode absolute paths.- Load config once at startup, store in a module-level object, access everywhere. Don't re-read env in hot paths.
That closes the How-To path's coverage of professional Python plumbing — databases, CLIs, and config. Next is the Advanced path: decorators, generators, and the protocol-style toolkit that completes Python's class system.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code actually did, not just whether a test passed.