Environment Variables & Configuration
1 · The lesson
readThe quickest way to leak a production database password to GitHub is to hardcode it in settings.py, then commit, push and forget about it. The second quickest is to commit your .env file by accident. Both mistakes are common, both can be fixed, and both are easy to avoid if you spend five minutes setting things up properly.
This lesson covers how professional Python apps load configuration: from environment variables, with .env for local development, converted to the right types and checked when the app starts, and never logged. This is the 12-Factor App approach: keep config separate from code, run the same code in dev, staging and prod, and change behaviour by changing env vars.
1. Why Config in Code Is a Problem
It is tempting to write this:
# settings.py (committed to git) DATABASE_URL = "postgres://prod-user:hunter2@db.example.com/app" API_KEY = "sk_live_AbCdEf1234567890" DEBUG = False
Every problem with this file appears soon after you deploy:
- Secrets in git history: even if you delete the file, the password stays in the history. The only safe fix is to rotate (replace) every credential in it. Rewriting the history afterwards does not undo the leak.
- No per-environment override: dev needs a different DB from prod, but the file can only hold one value.
- Hard to rotate: changing the password means a code change, a PR, a review and a deploy. This makes life hard for the people who run the system.
- Forks and leaks: anyone who clones the repo, including former 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, and the code is the same everywhere.
2. The 12-Factor Principle
From the 12-Factor App methodology, a widely used set of principles for cloud 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. File paths, credentials, hostnames, feature flags, log levels: none of these belong 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 it isn't set 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")
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 · re-runs 1 earlier block on this page first
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') # Earlier blocks on this page that this one builds on, re-run quietly first. try: import os except Exception: pass _pm_earlier = [ 'import os\n\n# Danger: KeyError if the variable isn\'t set\napi_key = os.environ["API_KEY"]\n\n# Safe: returns None (or your default) when it isn\'t set\napi_key = os.environ.get("API_KEY")\ndebug = os.environ.get("DEBUG", "false")', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
SystemExit prints your message and exits without a traceback, while a KeyError traceback is confusing in deployment logs. Failing 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. In production, the deployment platform (Heroku, Kubernetes, systemd, Docker) sets the env vars. There is usually no .env file there, so the call does nothing, and by default it never overrides variables that are already set.
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, and nothing secret reaches git.
5. Typed Config: Converting 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 · re-runs 2 earlier blocks on this page first
import os # noqa: F401 os.environ.setdefault("API_KEY", "example-api-key") os.environ.setdefault("DATABASE_URL", "postgresql://user:password@localhost:5432/example") # 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') # Earlier blocks on this page that this one builds on, re-run quietly first. try: import os from dotenv import load_dotenv except Exception: pass _pm_earlier = [ 'import os\n\n# Danger: KeyError if the variable isn\'t set\napi_key = os.environ["API_KEY"]\n\n# Safe: returns None (or your default) when it isn\'t set\napi_key = os.environ.get("API_KEY")\ndebug = os.environ.get("DEBUG", "false")', 'from dotenv import load_dotenv\nload_dotenv() # reads .env into os.environ\n\nimport os\nprint(os.environ["DATABASE_URL"]) # available everywhere', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
Booleans are a trap. Python's bool("false") is True, because 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 · re-runs 3 earlier blocks on this page first
import os # noqa: F401 os.environ.setdefault("API_KEY", "example-api-key") os.environ.setdefault("DATABASE_URL", "postgresql://user:password@localhost:5432/example") # 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') # Earlier blocks on this page that this one builds on, re-run quietly first. try: import os from dotenv import load_dotenv except Exception: pass _pm_earlier = [ 'import os\n\n# Danger: KeyError if the variable isn\'t set\napi_key = os.environ["API_KEY"]\n\n# Safe: returns None (or your default) when it isn\'t set\napi_key = os.environ.get("API_KEY")\ndebug = os.environ.get("DEBUG", "false")', 'from dotenv import load_dotenv\nload_dotenv() # reads .env into os.environ\n\nimport os\nprint(os.environ["DATABASE_URL"]) # available everywhere', 'port = int(os.getenv("PORT", "8000"))\nratio = float(os.getenv("SAMPLING_RATIO", "0.1"))', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
Accept 1/true/yes/on as truthy, and 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 · re-runs 4 earlier blocks on this page first
import os # noqa: F401 os.environ.setdefault("API_KEY", "example-api-key") os.environ.setdefault("DATABASE_URL", "postgresql://user:password@localhost:5432/example") # 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') # Earlier blocks on this page that this one builds on, re-run quietly first. try: import os from dotenv import load_dotenv except Exception: pass _pm_earlier = [ 'import os\n\n# Danger: KeyError if the variable isn\'t set\napi_key = os.environ["API_KEY"]\n\n# Safe: returns None (or your default) when it isn\'t set\napi_key = os.environ.get("API_KEY")\ndebug = os.environ.get("DEBUG", "false")', 'from dotenv import load_dotenv\nload_dotenv() # reads .env into os.environ\n\nimport os\nprint(os.environ["DATABASE_URL"]) # available everywhere', 'port = int(os.getenv("PORT", "8000"))\nratio = float(os.getenv("SAMPLING_RATIO", "0.1"))', 'def env_bool(name, default=False):\n raw = os.getenv(name)\n if raw is None:\n return default\n return raw.strip().lower() in ("1", "true", "yes", "on")\n\nDEBUG = env_bool("DEBUG") # True only for explicit truthy values', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
6. pydantic-settings: Typed Config in 10 Lines
For larger apps, writing all the conversion code yourself becomes tedious. pydantic-settings (Pydantic v2 moved BaseSettings into this separate package) 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 and .env, checks the values, converts the types print(settings.port) # int, not str print(settings.debug) # real bool
You get:
- Type conversion:
"8000"becomes8000,"true"becomesTrue. Lists are parsed too, but by default they must be written as JSON (["a", "b"]), not as comma-separated text. - Validation: a malformed
portraises a clearValidationErrorat startup. - Required vs optional: fields without defaults are required. If one is missing, the app stops at startup.
- Per-env files:
model_config = SettingsConfigDict(env_file=".env.prod")reads a different file.
This is the option for production apps. Use it once your config has more than about 5 fields.
7. Layered Config: Which Source Wins
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 the people who deploy the app can adjust it without code changes).
collections.ChainMap is one clean way to express this. See collections:
import os from collections import ChainMap defaults = {"port": 8000, "debug": False} file_config = load_yaml("config.yaml") # might be {} env_config = {k.removeprefix("APP_").lower(): v # APP_PORT becomes "port" (the value is a str) for k, v in os.environ.items() if k.startswith("APP_")} cli_config = {k: v for k, v in vars(args).items() # from argparse; flags not given if v is not None} # are None, so drop them config = ChainMap(cli_config, env_config, file_config, defaults)
setup added so this can run · defines load_yaml, args
# 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')
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` characters: 'sk_live_AbCdEf12' becomes '************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 (for sk_live_AbCdEf1234567890)
setup added so this can run · re-runs 1 earlier block on this page first
# 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') # Earlier blocks on this page that this one builds on, re-run quietly first. try: from pydantic_settings import BaseSettings, SettingsConfigDict except Exception: pass _pm_earlier = [ 'from pydantic_settings import BaseSettings, SettingsConfigDict\n\nclass Settings(BaseSettings):\n model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")\n\n database_url: str\n api_key: str\n debug: bool = False\n port: int = 8000\n allowed_hosts: list[str] = ["localhost"]\n\nsettings = Settings() # reads env and .env, checks the values, converts the types\nprint(settings.port) # int, not str\nprint(settings.debug) # real bool', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
When dataclass-style configs print themselves in a traceback, the secret would otherwise show up in the log. With dataclasses you can use field(repr=False) (see dataclasses), and with Pydantic you can use SecretStr, which prints as '**********'.
2. Never log the whole env.
# Very dangerous: writes 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 are often sent to several third-party services. One print(os.environ) for debugging, left in by accident, leaks every credential. Log specific keys, never the whole dict.
For managing secrets in production, the main 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. In your Python code, you still read the value with os.environ.get("API_KEY"), and the tool fills the environment before your process starts.
Local dev: .env plus optionally direnv, which loads a directory's env vars automatically when you cd into it.
9. Paths and "Where Am I?"
Two Path patterns come up constantly in config:
from pathlib import Path # Files relative to THIS script (for example, a default config shipped with the code) HERE = Path(__file__).resolve().parent DEFAULT_CONFIG = HERE / "defaults.yaml" # Files relative to where the USER invoked the script (for example, a local .env) cwd_env = Path.cwd() / ".env"
Path(__file__).parent is the directory of the source file. It stays the same wherever the user runs the command from. Use it for bundled resources.
Path.cwd() is the current working directory, which depends on where the user ran the command. Use it when you really want the user's working directory.
Hardcoded /Users/surya/... or C:\Users\... paths break as soon as the code runs on another machine. 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 set of config values.
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
# 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
# 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 only selects which set to use. Many platforms (Render, Fly, AWS) set env vars per environment, so you don't even need APP_ENV. But one explicit setting is useful when you need to check which config is running.
11. Common Mistakes
1. Committing .env to git. This is the worst mistake on this list. Add .env to .gitignore before you ever create the file. Then commit .env.example with placeholder values. For extra safety, install a pre-commit hook like git-secrets or gitleaks that checks for credentials staged by accident.
2. os.environ["X"] for optional variables. Hard KeyError on missing env vars is fine only if the variable is really 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, because any non-empty string is truthy
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')
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 one person on 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 gains nothing: a running process doesn't see changes made to its environment from outside, so a config change needs 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, converts types, and gives 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 variables, 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 means the user doesn't have to fix one, run again, and only then see the next.
Hint 2: Boolean conversion
raw.lower() in ("1", "true", "yes", "on"). If the variable isn't set at all, use 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." ) # Convert the types, with sensible defaults 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")
What you built:
- Required vs optional done right: required vars raise a clear error, optional ones have defaults.
- All missing variables reported together: 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 conversion:
PORTbecomes anint,DEBUGbecomes a properbool(notbool("false") == True). field(repr=False)onapi_key, so printing the config object doesn't leak the secret to logs.SystemExitfor missing config: a clean exit without a traceback. The exit code is non-zero, so deployment scripts can detect that the config is invalid.
The whole pattern is ~30 lines and replaces a settings.py with hardcoded values. When your app has more than about 5 fields, replace this with pydantic-settings. It does all of this plus validation, and the idea is the same.
What You Learned
- Config in code will sooner or later leak a secret. 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, and commit.env.example.- Env vars are always strings. Convert them with
int(),float()or a customenv_bool.bool("false")beingTrueis the classic mistake. pydantic-settingsfor typed, validated config in larger apps, with type conversion and field validation built in.- 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.
Together with databases and CLIs, this covers the practical setup work in a professional Python project. The last How-to lesson is about working with your own data. After that comes the Advanced path: decorators, generators, and the protocol-based tools that complete Python's class system.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code did, as well as whether a test passed.
Found this useful? Share it with a friend who’s learning Python.