mrkeyoor.com_
Wed 05 Aug 05:00 UTC
PyPIUtilsupdated 05 Aug 2026

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.

Verdict

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.

API stability5/5load_dotenv and dotenv_values have kept the same shape for years; the 1.x line ships small additive releases, and the biggest recent change was dropping old Python versions.
Docs4/5The README is the documentation and it covers the file format, expansion rules, CLI, and edge cases like valueless variables well; there is no dedicated docs site, and some behavior details live in issues.
Maintenance4/5Two named maintainers, last push July 2026, 8.8k stars, 106 open issues and PRs; the release cadence is slow, but the surface is tiny so that rarely matters in practice.
Ecosystem5/5187M weekly downloads and de facto standard status: Flask CLI integration and uvicorn --env-file go through it, and nearly every Python deployment tutorial assumes a .env file.

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
Skip it if

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}/app

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

The 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=1

Stops load_dotenv() from loading files or streams; the escape hatch when third-party code calls it in environments where .env must not load.

Alternatives

PackageRegistryPick it when
pydantic-settingsPyPIYou want typed, validated settings with .env support; the better default in new FastAPI-era codebases
environsPyPIYou want env parsing with casting and validation built in, without adopting pydantic
python-decouplePyPISimilar .env loading with casting and defaults; popular in Django projects
dynaconfPyPIConfig has outgrown one flat file: multiple formats, environments, and secrets backends