.env File Management
A .env file is the most convenient way to run an app locally and the most common way teams leak credentials into git. The danger is twofold: the file overwrites real platform secrets when loaded carelessly, and it ends up committed. This page makes .env loading safe in both directions.
Within the configuration pipeline, the .env file is a development-only source that must sit below the real environment variables in the precedence order — it fills gaps locally, never overrides production.
Secure implementation
# config/dotenv_loader.py
import os
from pathlib import Path
from dotenv import dotenv_values
def load_env(path: str = ".env") -> None:
env_path = Path(path)
if not env_path.exists():
return # production has no .env; that's fine
file_values = dotenv_values(env_path) # isolated dict, not a mutation
for key, value in file_values.items():
if value is not None and key not in os.environ: # override=False semantics
os.environ[key] = value # only fill genuinely missing keys
By reading into a dict and writing back only the missing keys, a platform-injected DATABASE_URL is never clobbered by a stale local one. This is override=False implemented explicitly.
Configuration reference
| Option | Type | Default | Security implication |
|---|---|---|---|
dotenv_values(path) |
dict |
— | Isolated; no os.environ mutation |
load_dotenv(override=...) |
bool |
False |
True overwrites real injected secrets — avoid |
.env in .gitignore |
n/a | — | Prevents the most common secret leak |
.env.example |
file | — | Documents required keys without real values |
Deployment parity: local to production
- Local dev — developer keeps an uncommitted
.env;load_env()fills missing keys only. - CI — no
.envfile exists; required keys come from pipeline variables and the secret store. - Staging/Production — the orchestrator injects real values;
load_env()is a no-op because the file is absent and keys are already set.
The same code path runs everywhere; only the presence of the file differs.
Security boundaries & guardrails
.envis always in.gitignore; a committed.env.examplecarries dummy values only.- Run
gitleaksordetect-secretsas a pre-commit hook to block accidental commits. - Set file permissions to
600so other local users cannot read it. - Keep
override=False; never let a local file win over an injected secret. - Wrap any secret read from
.envinSecretStrinside the settings model.
Troubleshooting
- Production secret overwritten by
.env— you calledload_dotenv(override=True); switch to the isolated-merge pattern above. .envaccidentally committed — rotate every credential it contained immediately, then purge it from history and add the pre-commit scanner.- Variable not picked up — the key already exists in
os.environ; withoverride=Falsethat is intentional, the existing value wins. SyntaxWarningon load in Python 3.12 — unescaped backslashes in values; see Safely Load .env Files in Python 3.12.
Frequently asked questions
What is the difference between load_dotenv and dotenv_values?
load_dotenv mutates os.environ in place, which can overwrite platform-injected values. dotenv_values returns an isolated dict you can merge deliberately — safer in containers where the orchestrator already set real values.
Should override be True or False when loading a .env file?
override=False (the default) is correct for production parity — a value already present in the environment, such as a Kubernetes secret, must win over the local .env. Use override=True only in isolated local testing.
How do I stop a .env file from being committed?
Add .env to .gitignore, commit a .env.example with dummy values instead, and run a secret scanner as a pre-commit hook so a leak is blocked before it reaches history.
Conclusion
The invariant: a .env file may fill missing local values but may never override an injected one, and it never enters version control. Load it into an isolated dict, merge with override=False semantics, and gate it with gitignore plus a pre-commit scanner.