os.environ vs python-dotenv: best practices

os.environ and python-dotenv solve different halves of the same problem, and confusing them is how a local .env ends up overwriting a production secret. This page draws the line. It extends Environment Variables & os.environ.

Problem 1: treating .env as the source of truth

# ANTI-PATTERN: production has no .env, so this raises or returns junk
from dotenv import load_dotenv
load_dotenv()
db = os.environ["DATABASE_URL"]   # only works if .env exists — it shouldn't in prod

python-dotenv is a local convenience. In production the values come from the orchestrator, and there is no .env file at all.

Problem 2: override flips the precedence

# ANTI-PATTERN: local file wins over the injected secret
load_dotenv(override=True)        # stale .env beats the real environment

With override=True, the developer’s file outranks the platform — the exact inversion of what production parity requires.

Secure implementation

# config/env_source.py
import os
from pathlib import Path
from dotenv import dotenv_values

def hydrate_from_dotenv() -> None:
    """Fill ONLY missing keys; os.environ stays authoritative."""
    path = Path(".env")
    if not path.exists():
        return
    for key, value in dotenv_values(path).items():
        if value and key not in os.environ:   # override=False, explicitly
            os.environ[key] = value

# os.environ is the single source the app reads; dotenv only seeds gaps locally.
hydrate_from_dotenv()
DATABASE_URL = os.environ["DATABASE_URL"]      # same line works in dev and prod

The application always reads os.environ. python-dotenv merely seeds missing keys during local development, and only when they are not already set.

Gotchas & version-specific behaviour

  • load_dotenv() mutates os.environ; dotenv_values() does not — prefer the latter for control.
  • python-dotenv is a dev/test dependency; it should not be required for the app to boot in production.
  • Env-var values are strings; type them through a typed accessor or a pydantic model.
  • case_sensitive differs by platform — be explicit in your settings model.

Production parity checklist

  • The app reads os.environ only; .env seeds gaps locally with override=False.
  • .env is gitignored; .env.example documents the keys.
  • Production injects values via the orchestrator — no .env shipped.
  • Required keys validated at startup; secrets wrapped in SecretStr.

Conclusion

Read os.environ; use python-dotenv only to seed missing keys locally, never to override. That keeps one code path identical across environments. For typed reads of those values, see Reading Typed Env Vars.