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.envvalues (Windows paths) trigger it; resolve paths withpathliband quote values. - Run staging with
PYTHONWARNINGS=error::SyntaxWarningto catch regressions in CI. dotenv_valuesreturnsstr | None; guard againstNonebefore assigning.os.environaccepts strings only — a non-string value raisesTypeErrorin 3.12.
Production parity checklist
.envis gitignored; a.env.examplewith 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=Falsesemantics 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.