How to safely load .env files in Python 3.12

Python 3.12 turned several quiet behaviours into loud warnings, and a careless .env loader now emits SyntaxWarning, leaks secrets into subprocesses, and overwrites platform-injected credentials. This page is the hardened pattern. It extends the .env File Management cluster.

Problem 1: override=True clobbers injected secrets

# ANTI-PATTERN: overwrites the platform's real DATABASE_URL with a stale local one
from dotenv import load_dotenv
load_dotenv(override=True)

In a container the orchestrator already set DATABASE_URL; override=True replaces it with whatever stale value the local .env happens to contain.

Problem 2: the mutated environment leaks into subprocesses

# ANTI-PATTERN: the curl subprocess inherits every secret in os.environ
import os, subprocess
subprocess.run(["curl", "https://api.example.com"])   # inherits os.environ wholesale

Once .env values are pushed into os.environ, every child process inherits them — including third-party binaries you did not write.

Secure implementation

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

ENV_PATH = Path(__file__).resolve().parent / ".env"   # explicit path, no 3.12 escape warnings

def load_env() -> None:
    if not ENV_PATH.exists():
        return                                        # production has no .env file
    for key, value in dotenv_values(ENV_PATH).items():
        if value and key not in os.environ:           # override=False semantics
            os.environ[key] = value

def require(*keys: str) -> None:
    missing = [k for k in keys if not os.environ.get(k)]
    if missing:
        sys.exit(f"FATAL: missing required config: {', '.join(missing)}")

load_env()
require("DATABASE_URL", "API_KEY")
# Hand subprocesses an explicit, minimal env — never the full os.environ.
subprocess.run(["curl", "https://api.example.com"], env={"PATH": os.environ["PATH"]})

dotenv_values reads into a dict instead of mutating the environment; missing keys are filled but injected secrets always win; subprocesses get an explicit minimal env.

Gotchas & version-specific behaviour

  • Python 3.12 SyntaxWarning — unescaped backslashes in .env values (Windows paths) trigger it; resolve paths with pathlib and quote values.
  • Run staging with PYTHONWARNINGS=error::SyntaxWarning to catch regressions in CI.
  • dotenv_values returns str | None; guard against None before assigning.
  • os.environ accepts strings only — a non-string value raises TypeError in 3.12.

Production parity checklist

  • .env is gitignored; a .env.example with dummy values is committed instead.
  • A secret scanner (gitleaks/detect-secrets) runs as a pre-commit hook.
  • Required keys are validated at startup with a fail-fast require().
  • Subprocesses receive an explicit env, never the inherited environment.
  • override=False semantics keep injected secrets authoritative.

Conclusion

Read .env into a dict, fill only missing keys, validate the required set, and never hand the full environment to a subprocess. For the gitignore and precedence rules around this pattern, see .env File Management.