PythonMastery
intermediate 18 min read · lesson 13 of 14 in Python How-To

Environment Variables & Configuration

1 · The lesson

read

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

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

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

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

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

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

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

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

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 and .env, checks the values, converts the types
print(settings.port)                         # int, not str
print(settings.debug)                        # real bool

You get:

  • Type conversion: "8000" becomes 8000, "true" becomes True. Lists are parsed too, but by default they must be written as JSON (["a", "b"]), not as comma-separated text.
  • Validation: a malformed port raises a clear ValidationError at 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:

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 the people who deploy the app can adjust it without code changes).

collections.ChainMap is one clean way to express this. See collections:

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

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

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

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

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

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

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

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

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 variables, 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 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
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."
            )

        # 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_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 conversion: PORT becomes an int, DEBUG becomes a proper bool (not bool("false") == True).
  • field(repr=False) on api_key, so printing the config object doesn't leak the secret to logs.
  • SystemExit for 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 a required_env helper) for required config that should fail fast.
  • .env files for local dev with python-dotenv. Gitignore .env, and commit .env.example.
  • Env vars are always strings. Convert them with int(), float() or a custom env_bool. bool("false") being True is the classic mistake.
  • pydantic-settings for typed, validated config in larger apps, with type conversion and field validation built in.
  • 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.

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.

Next: Working With Your Own Data Files.

Practice this

on practicepython.in

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