PythonMastery
intermediate 18 min read · lesson 11 of 12 in Python How-To

Environment Variables & Configuration

1 · The lesson

read

The 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:

python
# 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:

python
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 — the KeyError is 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:

python
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:

text
# .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:

bash
pip install python-dotenv
python
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:

text
# .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:

python
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:

python
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:

python
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:

bash
pip install pydantic-settings
python
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" becomes 8000, "true" becomes True. List parsing for free.
  • Validation — a malformed port raises a clear ValidationError at 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:

text
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:

python
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.

python
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.

python
# 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:

ToolWhere it fits
AWS Secrets ManagerAWS-native apps; rotation built in
HashiCorp VaultSelf-hosted, multi-cloud, fine-grained policies
GCP Secret ManagerGCP-native equivalent
Azure Key VaultAzure equivalent
doppler.com / 1Password SecretsHosted 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:

python
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.

python
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:

python
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.

python
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.

python
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:

python
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 list missing = [], 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
python
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_URL and API_KEY are missing, the user sees both in one message instead of fixing one and re-running to discover the next.
  • Real type coercion — PORT becomes an int, DEBUG becomes a proper bool (not bool("false") == True).
  • field(repr=False) on api_key — printing the config object doesn't leak the secret to logs.
  • SystemExit for 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 a required_env helper) for required config that should fail fast.
  • .env files for local dev with python-dotenv. Gitignore .env; commit .env.example.
  • Env vars are always strings — coerce with int(), float(), custom env_bool. bool("false") is True is the canonical gotcha.
  • pydantic-settings for typed, validated config in larger apps — type coercion and field validation for free.
  • Layered config: CLI > env > file > defaults. ChainMap expresses this cleanly — see collections.
  • Never log secrets. field(repr=False) on dataclass secrets, SecretStr in Pydantic, a mask_secret helper for logs.
  • Path(__file__).parent for 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.in

Short exercises that run in your browser and tell you what your code actually did, not just whether a test passed.