.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.

Blast radius of the three loading approaches Sourcing a file exposes secrets to the whole shell session and every child process, direnv scopes them to the project directory, and python-dotenv confines them to a single process. how far do the values travel? source .env whole shell session · every child process · until the terminal closes direnv this directory and below · unloaded when you leave python-dotenv one process · exits with it narrower is safer
The three tools differ mainly in scope — and scope is what decides how much a mistake exposes.

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
The loader is optional, validation is not In development the loader imports python-dotenv and fills missing keys, while in production the import fails harmlessly and the settings model raises on any missing required key. development python-dotenv installed .env present → keys filled applied keys logged convenient, and observable production python-dotenv not installed ImportError → return {} settings model validates missing key fails the boot
The loader disappears in production; the validation that catches a missing key does not.

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.

The direnv trust and lifecycle model Entering the directory loads the environment only if the .envrc has been approved; changing the file revokes approval until it is reviewed again; leaving the directory unloads the variables. cd into repo .envrc found approved? direnv allow vars loaded for this tree only cd out unloaded file edited → approval revoked A pulled branch cannot execute a modified .envrc until a human reviews and re-approves it.
Approval is per-file-content, so any change to .envrc forces a fresh look before it can run.

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.

Choosing between the two viable loaders python-dotenv suits Python-only workflows and requires no shell setup, while direnv suits mixed toolchains and multiple repositories; both read the same untracked file. both read the same untracked .env python-dotenv Python-only workflow no shell setup for teammates works in IDE run configs the safe default direnv psql, make, mixed CLIs many repos, many contexts per-directory activation an individual's add-on
Neither choice affects production, which is why it can stay a per-developer preference.

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.

Failure behaviour of the three approaches Across a new required key, a branch switch, an idle terminal, and an environment dump, the in-process loader and direnv behave predictably while a sourced shell retains stale values. situation source .env direnv dotenv new required key added stale export hides it fails clearly fails clearly branch switch changes values old values persist reloads re-reads terminal left open overnight secrets still live unloaded gone something dumps the env everything leaks repo keys only one process
The comparison is decided by the failure rows, not the success rows — every option works when nothing goes wrong.

Gotchas & version-specific behaviour

  • direnv requires direnv allow per .envrc — a deliberate trust step, re-triggered whenever the file changes.
  • python-dotenv should be a dev/test dependency, not required to boot in production.
  • All three need .env in .gitignore; the tool choice does not change that.
  • Keep override=False semantics so injected values win regardless of tool.
  • direnv exports into the shell, so a long-running process started inside the directory keeps the variables even after you cd out — unloading affects new processes, not existing ones.
  • IDE run configurations and debuggers usually do not inherit a direnv environment, because they are launched by the editor rather than by your shell; python-dotenv covers 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

  • .env is gitignored; .env.example documents 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.