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()mutatesos.environ;dotenv_values()does not — prefer the latter for control.python-dotenvis 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_sensitivediffers by platform — be explicit in your settings model.
Production parity checklist
- The app reads
os.environonly;.envseeds gaps locally withoverride=False. .envis gitignored;.env.exampledocuments the keys.- Production injects values via the orchestrator — no
.envshipped. - 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.