python-dotenv
python-dotenv reads key-value pairs from a .env file and loads them into os.environ, so an app configured by environment variables (12-factor style) runs locally without you exporting a dozen variables by hand. That is the whole job: load_dotenv() for the common case, dotenv_values() when you want a dict without touching the environment, a small CLI (dotenv set/list/run), and a bash-like file format with quoting, multiline values, and ${VAR} interpolation.
The boring, correct choice for local development configuration in Python and safe to adopt anywhere. Keep .env out of git, and use real environment variables or a secrets manager in production.
Use it if
- Your app reads config from os.environ and you want local development to pick values up from a .env file automatically
- You need layered config: merging .env.shared, .env.secret, and the real environment via dotenv_values() dicts
- You want a dependency with zero required transitive packages that does exactly one thing
- You use dotenv run to inject variables into any command without touching your shell profile
- You are configuring production on a real platform: Docker, systemd, Kubernetes, and every PaaS already inject environment variables, and a .env file there is just an unencrypted secrets file on disk
- You want types and validation: pydantic-settings reads .env files and gives you typed, validated settings objects instead of raw strings
- Your framework already loads .env: the Flask CLI and uvicorn --env-file do it for you, so calling load_dotenv() yourself can duplicate or conflict with that
- You need actual secrets management: this is plaintext on disk, not a substitute for Vault, SOPS, or your cloud secret manager
Setup reality
pip install python-dotenv and call load_dotenv() before anything reads os.environ, which in practice means the first lines of your entrypoint; modules that read env vars at import time will beat a late call and see nothing. The gotchas are behavioral, not install: existing environment variables win unless you pass override=True, the default upward .env search behaves differently in notebooks and frozen apps, and quoting plus ${VAR} expansion rules differ from docker compose env-file parsing just enough to bite. Version 1.2 requires Python 3.10+.
Patterns
Load .env into the environmentload-basic
import os
from dotenv import load_dotenv
load_dotenv() # searches from the caller's directory upward
db_url = os.getenv("DATABASE_URL")Call it before any module reads os.environ; variables already set in the environment win unless you pass override=True.
Let the file beat the environmentoverride-existing
from dotenv import load_dotenv
load_dotenv(override=True)Default is override=False, so a variable exported in your shell silently shadows the .env value; this flips the precedence.
Parse without touching os.environvalues-dict
from dotenv import dotenv_values
config = dotenv_values(".env")
# {"USER": "foo", "EMAIL": "foo@example.org"}Keys declared with no value come back as None, while KEY= gives an empty string; code that expects strings everywhere should handle both.
Merge shared, secret, and real envlayered-config
import os
from dotenv import dotenv_values
config = {
**dotenv_values(".env.shared"),
**dotenv_values(".env.secret"),
**os.environ, # real environment wins
}Straight from the README; later spreads win, so keep os.environ last if the actual environment should take precedence.
Load a specific .env fileexplicit-path
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent / ".env")The default upward search keys off the calling file, which misbehaves in notebooks, REPLs, and frozen binaries; an explicit path is deterministic.
Search from the working directoryfind-dotenv
from dotenv import load_dotenv, find_dotenv
load_dotenv(find_dotenv(usecwd=True))usecwd=True searches from the current working directory instead of the library call site; useful under pytest and console entry points.
Load config from a string or socketload-from-stream
from io import StringIO
from dotenv import load_dotenv
load_dotenv(stream=StringIO("USER=foo\nEMAIL=foo@example.org"))Lets you load config fetched from a network source or secrets API without writing a temp file.
Interpolate variables inside .envvariable-expansion
# .env
DOMAIN=example.org
ADMIN_EMAIL=admin@${DOMAIN}
ROOT_URL=${DOMAIN}/appOnly braced ${VAR} references expand; a bare $VAR stays a literal string.
Manage .env from the command linecli-usage
pip install "python-dotenv[cli]"
dotenv set USER foo
dotenv list --format=json
dotenv run -- python app.pyThe dotenv command is behind the [cli] extra; a plain install does not ship it.
Read and write .env entries from codeedit-programmatically
from dotenv import set_key, get_key, unset_key
set_key(".env", "API_KEY", "abc123")
print(get_key(".env", "API_KEY"))
unset_key(".env", "API_KEY")set_key rewrites the file while preserving other lines; fine for setup scripts, wrong for anything with concurrent writers.
Disable loading globallydisable-in-prod
export PYTHON_DOTENV_DISABLED=1Stops load_dotenv() from loading files or streams; the escape hatch when third-party code calls it in environments where .env must not load.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| pydantic-settings | PyPI | You want typed, validated settings with .env support; the better default in new FastAPI-era codebases |
| environs | PyPI | You want env parsing with casting and validation built in, without adopting pydantic |
| python-decouple | PyPI | Similar .env loading with casting and defaults; popular in Django projects |
| dynaconf | PyPI | Config has outgrown one flat file: multiple formats, environments, and secrets backends |