.env vs python-dotenv vs direnv for local dev
Three tools claim to “load your .env”: a raw file your shell sources, python-dotenv inside the app, and direnv at the shell level. They have different blast radii and different parity stories. This page picks between them, extending .env File Management.
The right way to compare them is by scope — how far the values travel and how long they live. Sourcing a file puts secrets in your shell for the rest of the session and in every process you launch from it, including a browser opened from the terminal or an editor started with code .. python-dotenv confines them to one Python process that exits when the program does. direnv sits in between: values live in the shell, but only while you are inside the project directory, and vanish when you leave. Nothing else about these tools matters as much as that difference, because scope is what determines how much is exposed when something goes wrong.
Problem 1: sourcing .env into your interactive shell
# ANTI-PATTERN: secrets persist in your whole shell session
set -a; source .env; set +a # every later command — and child — inherits them
Every subsequent command in that terminal, including unrelated tools, inherits the secrets. That is not a theoretical concern: a curl invocation that dumps its environment on error, a package manager’s postinstall script, a crash reporter that attaches environment context, and any process started from that shell all receive the production-adjacent credentials you loaded for one task. The values also outlive their purpose — you finish the task, move on to something else in the same terminal, and the secrets are still there hours later.
There is a second, quieter problem. Because the shell now has the values, an application started from it appears to work even when its own loading is broken. You “fix” a configuration issue by sourcing the file, the app runs, and the actual bug — a loader pointing at the wrong path, or a required key nobody declared — stays hidden until it surfaces in CI, where nobody sourced anything. Sourcing masks exactly the failures you want to see locally, which is the opposite of what a development environment is for — local runs should be the place where a configuration mistake is cheapest to find, not the place where it is best hidden. Once the habit is established it is also hard to dislodge, because the sourcing line usually migrates into a shell profile where nobody thinks about it again.
Problem 2: app code that requires the file to exist
# ANTI-PATTERN: production has no .env, so this is fragile
from dotenv import load_dotenv
load_dotenv() # silently does nothing in prod; masks missing config
python-dotenv is great locally but must be a no-op (and a non-dependency) in production. The call above is not wrong so much as unfinished: it succeeds whether or not the file exists, so a missing production variable produces no signal at import time and the process continues into whatever the settings layer does with an unset key. Pairing the loader with a settings model that declares required fields is what supplies the missing signal — the loader stays silent, and validation raises.
The dependency question is worth settling explicitly. If python-dotenv is a runtime requirement, it ships in the production image and the import runs on every boot, which means a supply-chain issue in a development convenience reaches production. Declaring it as a development or test extra and guarding the import keeps the production path free of it entirely, at the cost of three lines.
# config/loader.py — the loader is optional in production
import os
from pathlib import Path
def hydrate(path: Path = Path(".env")) -> dict[str, str]:
try:
from dotenv import dotenv_values # dev/test extra, not a runtime dep
except ImportError:
return {} # production: nothing to load
applied = {}
for key, value in dotenv_values(path).items():
if value and key not in os.environ: # never override injected values
os.environ[key] = value
applied[key] = str(path)
return applied
Secure implementation
# .envrc — direnv: auto-loads per directory, unloads when you leave it
dotenv # direnv reads .env when you cd in, clears it when you cd out
python-dotenv keeps the values inside the process; direnv scopes them to the project directory and unloads on exit — both better than sourcing into your whole shell. All three rely on .env being gitignored.
direnv earns its place when your workflow is not only Python. If you run psql against the local database, invoke a Makefile target, or use a CLI that reads the same variables, an in-process loader helps none of them, and you end up sourcing the file anyway — which is where the blast radius problem started. direnv gives those tools the variables while you are in the directory and takes them away when you cd .., which is the shell-level equivalent of process scope.
Its trust model is the part worth understanding. .envrc is a shell script, so it can run arbitrary commands; direnv therefore refuses to execute one until you run direnv allow in that directory, and it re-blocks the file whenever its contents change. That makes a pulled branch containing a modified .envrc inert until you look at it and approve it — a genuinely useful property, and one that makes .envrc safe to commit as long as it contains only the dotenv directive and no values. The pattern that works is a committed .envrc with no secrets, loading an untracked .env that has them.
.envrc can also do things a plain file cannot, and this is where teams either gain a lot or overreach. Activating a virtualenv, setting PYTHONPATH, or exporting a computed value — a database URL assembled from a port that varies per developer — are all reasonable, because none of them are secrets and all of them are reproducible from the repository. What does not belong there is anything fetched at directory-entry time: a .envrc that shells out to a secret store runs on every cd, turns a directory change into a network call, and produces baffling delays and auth prompts. Keep the file declarative and let the values come from .env.
Comparison
| Tool | Scope | Loads into | Best for |
|---|---|---|---|
raw .env + source |
whole shell session | your interactive shell | nothing — too broad |
python-dotenv |
the Python process | os.environ (in-process) |
app-level local config |
direnv |
the project directory | the shell, auto-unloaded | per-repo dev environments |
The two viable options are not mutually exclusive, and the combination is often the right answer. Let python-dotenv handle the application, so a developer who has not installed anything extra can still clone and run; add direnv for people who work across several repositories or who need the variables in shell tools as well. Both read the same untracked .env, both keep the real environment on top, and neither changes what production does — so adopting direnv is a personal preference rather than a team-wide decision that has to be coordinated.
What each tool does when something goes wrong
Comparing tools on their happy path tells you little; what separates them is the failure behaviour. Take the four situations that actually occur during a week of local development and read each tool’s response.
A teammate adds a required setting. With python-dotenv, the next run fails validation with the field name, because the key is absent from both the file and the environment — clear and immediate. With direnv, the same thing happens, since direnv only supplies what the file contains. With a sourced shell, the failure may not occur at all if you happen to still have an old export from a previous session, so you discover the new requirement when CI fails or, worse, when a colleague cannot start the service.
You switch branches. python-dotenv re-reads the file on every process start, so the values are always current. direnv reloads when the file changes and re-blocks if .envrc itself was modified, which is exactly the review prompt you want. A sourced shell keeps the old values indefinitely, which is the single most common cause of “it works in this terminal but not that one” — two windows, two different vintages of the same variable.
You finish for the day and leave the terminal open. Process scope means nothing persists; the values died with the last run. direnv unloads them the moment you leave the directory. The sourced shell still holds live credentials in a window that will be reused tomorrow for something unrelated, which is precisely the exposure you were trying to avoid by keeping secrets out of the repository in the first place.
Something dumps the environment — a crash reporter, a verbose CI step run locally, a docker run --env-file you copied from somewhere. The narrower the scope, the fewer secrets are in the dump. This is the argument that settles the comparison: every tool works when nothing goes wrong, and the tool with the narrowest scope leaks the least when something does.
Gotchas & version-specific behaviour
direnvrequiresdirenv allowper.envrc— a deliberate trust step, re-triggered whenever the file changes.python-dotenvshould be a dev/test dependency, not required to boot in production.- All three need
.envin.gitignore; the tool choice does not change that. - Keep
override=Falsesemantics so injected values win regardless of tool. direnvexports into the shell, so a long-running process started inside the directory keeps the variables even after youcdout — unloading affects new processes, not existing ones.- IDE run configurations and debuggers usually do not inherit a
direnvenvironment, because they are launched by the editor rather than by your shell;python-dotenvcovers that gap.
That last pair explains most of the confusion people hit after adopting direnv. It manages the shell’s environment, so anything the shell starts is covered and anything started elsewhere is not — a test run from the terminal sees the variables, the same test run from the editor’s green arrow may not. Keeping the in-process loader as well means both paths work, and the loader is a harmless no-op in the terminal case, because direnv has already set the keys and the merge skips every key that is present.
Production parity checklist
.envis gitignored;.env.exampledocuments the keys.- Production injects variables via the orchestrator — no tool loads a file there.
- The app reads
os.environ; the loader only seeds missing keys. - Secrets are never sourced into the interactive shell.
- The loader logs which keys it supplied, so “where did this value come from?” has an answer.
.envrc, if committed, contains directives only — never values.
Auditing an existing setup against this list takes about a minute. Run env | wc -l in a terminal you have been using for a while, then open a fresh one and run it again; a large difference means something is exporting into your session and staying there. Grep the shell profile for source .env or set -a, since the habit often lives in a .bashrc rather than in anything project-specific, and it survives every attempt to fix it at the repository level. Finally, check that python-dotenv sits in a development extra rather than the main dependency list, which is a one-line change with a real effect on what ships.
Key takeaways
Use python-dotenv (or direnv) to scope local secrets to the process or directory; never source them into your shell. The production path stays identical because the app only ever reads os.environ, and the loader is a development convenience that vanishes — by import guard or by absence of the file — everywhere else. Choose by scope first and by convenience second: the in-process loader is the narrowest and the safe default, direnv is worth adding when shell tools need the same values, and sourcing is the one option with no scope at all. If you adopt only one thing from this comparison, make it the removal of source .env from your shell profile: it is the change with the largest reduction in exposure and the smallest effect on how you actually work day to day. See os.environ vs python-dotenv for the read side.