A .env file looks like the simplest config format there is. It has no spec, though. Every loader implements its own dialect, and the dialects disagree on a handful of edge cases. The same line can give different values to a Docker container and a Python app in the same project, and nothing tells you.
This post shows the problem with code. We start with one innocent line, build a small conformance harness that feeds a corpus of .env fixtures to six readers, and look at what comes out. Then we use differential fuzzing to find disagreements nobody listed.
All code, fixtures and result files are in the companion repo: urwithajit9/dotenv-difference.
What we found, in short:
- 13 of 16 fixtures produce different values depending on the reader. Only bare keys, duplicate keys and an empty file come out identical everywhere.
-
One line gets three different values.
API_KEY= # fill this in lateris read as the comment text, as the comment text with leading spaces, or as an empty string. -
Compose and
docker run --env-fileare different parsers. They disagree on 9 of 16 fixtures. -
Node silently truncates at any
#.Tr0ub4dor#3becomesTr0ub4dor. -
Fuzzing finds more. Nearly half of random one-line files are read differently by python-dotenv and Node, and some of the causes (name grammar,
export =v) are not in any list of known differences.
Measured environment
Everything marked measured was run for this post, on one machine, against the same 16 fixtures.
| Component | Version |
|---|---|
| Python | 3.14.4 |
| python-dotenv | 1.2.2 |
| Node | v22.22.1 |
dotenv (npm) |
18.0.9 |
dotenv-expand (npm) |
12.0.1 |
| bash | GNU bash 5.x (bash --version) |
| Docker | 29.6.2 (build dfc4efb) |
| Docker Compose | v5.3.1 |
Not measured: godotenv, dotenvy and Ruby dotenv. The harness is built so you can add them (see "Extending the harness"). The evnx 0.10.18 value in the first table comes from an earlier run of the same line on 2026-10-27 and is not part of the harness.
1. The line that started this
API_KEY= # fill this in later
The author's intent is clear: the key is empty, and the comment is a note to self. What does each reader think?
| Reader | Value of API_KEY
|
Status |
|---|---|---|
| Docker Compose v5.3.1 | # fill this in later |
measured |
| python-dotenv 1.2.2 | # fill this in later |
measured |
| evnx 0.10.18 | # fill this in later |
measured (earlier run) |
docker run --env-file |
# fill this in later (three leading spaces kept) |
measured |
| Node dotenv 18.0.9 |
"" (empty) |
measured |
bash source
|
"" (empty) |
measured |
Three different values. Compose and python-dotenv keep the comment text. docker run --env-file keeps the comment text and the whitespace in front of it. Node and bash treat the value as empty. Anything that reads this file and checks "is API_KEY set?" gets a different answer depending on the reader.
A correction worth making: an earlier draft of the underlying issue assumed python-dotenv returns an empty string here. It doesn't. It agrees with Compose. That is exactly why you should measure instead of writing the table from memory.
2. A tiny harness
The idea: one directory of fixtures, one adapter per reader that reads a fixture (or a whole directory) and prints what it sees as JSON, and a script that builds a comparison table from the JSON files.
dotenv-difference/
├── make_cases.py # writes cases/*.env
├── readers/
│ ├── py_dotenv.py # python-dotenv, one file
│ ├── py_dotenv_all.py # python-dotenv, whole directory
│ ├── node_dotenv_all.js # Node dotenv (+ dotenv-expand)
│ ├── bash_source.sh # bash `source`
│ └── docker_env.sh # Compose and `docker run --env-file`
├── compare.py # results/*.json -> comparison table
├── fuzz.py # differential fuzzing
├── run_all.sh # regenerate everything
└── results/ # JSON output, committed
Fixtures
Each fixture is a small file named for the case. Create them with a script so the bytes are exact (CRLF and BOM are easy to mangle in an editor, and printf behaves differently across shells):
# make_cases.py
from pathlib import Path
CASES = {
"empty_comment": b"API_KEY= # fill this in later\n",
"empty_comment2": b"EMPTY_C2= #c\n",
"value_comment": b"V_C=x # c\n",
"hash_in_value": b"HASH=Tr0ub4dor#3\n",
"hash_start": b"COLOR=#ff0000\n",
"bare": b"BARE=\n",
"export": b"export KEY=v\n",
"expand": b"HOST=db\nURL=${HOST}:5432\n",
"escapes": b'A="a\\nb"\nB=\'a\\nb\'\n',
"dup": b"K=1\nK=2\n",
"spaces": b"KEY = v \nK2=v \n",
"multiline": b'PEM="-----BEGIN\nabc\n-----END"\n',
"crlf": b"K=v\r\nL=w\r\n",
"bom": b"\xef\xbb\xbfK=v\n",
"empty_file": b"",
"unterminated": b'K="unterminated\nL=next\n',
}
out = Path("cases")
out.mkdir(exist_ok=True)
for name, data in CASES.items():
(out / f"{name}.env").write_bytes(data)
Run it. It prints nothing, but it creates a cases/ directory with 16 fixtures:
user@host:~/dotenv-difference$ python3 make_cases.py
user@host:~/dotenv-difference$ ls cases/
bare.env bom.env crlf.env dup.env
empty_comment.env empty_comment2.env empty_file.env escapes.env
expand.env export.env hash_in_value.env hash_start.env
multiline.env spaces.env unterminated.env value_comment.env
3. Readers and adapters
Each adapter takes a file path or a directory and prints a JSON object of what that reader sees.
Python: python-dotenv
Create a virtual environment (recent Linux distributions block system-wide pip install) and pin the version used in this post:
user@host:~/dotenv-difference$ python3 -m venv .venv
user@host:~/dotenv-difference$ source .venv/bin/activate
(.venv) user@host:~/dotenv-difference$ pip install python-dotenv==1.2.2
The smallest possible adapter:
# readers/py_dotenv.py
import sys, json
from dotenv import dotenv_values
print(json.dumps(dict(dotenv_values(sys.argv[1])), ensure_ascii=False))
It needs the fixture path as an argument. Run it without one and you get an IndexError:
(.venv) user@host:~/dotenv-difference$ python3 readers/py_dotenv.py cases/empty_comment.env
{"API_KEY": "# fill this in later"}
python-dotenv keeps the comment as the value, which is the first surprise in this post.
For the whole fixture directory, use this version. It writes one JSON file and records a failure instead of crashing:
# readers/py_dotenv_all.py
import argparse, json, sys
from pathlib import Path
from dotenv import dotenv_values
def read(path):
try:
return dict(dotenv_values(path))
except Exception as e:
# Record the failure instead of crashing the whole run
return {"error": f"{type(e).__name__}: {e}"}
def main():
ap = argparse.ArgumentParser(description="Parse .env files with python-dotenv and emit JSON")
ap.add_argument("path", help="a .env file, or a directory of .env files")
ap.add_argument("--out", help="write JSON to this file instead of stdout")
args = ap.parse_args()
p = Path(args.path)
if p.is_dir():
result = {f.stem: read(f) for f in sorted(p.glob("*.env"))}
else:
result = read(p)
# ensure_ascii stays on, so a BOM shows up as \ufeff instead of an invisible character
text = json.dumps(result, indent=2, sort_keys=True) + "\n"
if args.out:
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
Path(args.out).write_text(text, encoding="utf-8")
else:
sys.stdout.write(text)
if __name__ == "__main__":
main()
(.venv) user@host:~/dotenv-difference$ python3 readers/py_dotenv_all.py cases/ --out results/python-dotenv.json
python-dotenv could not parse statement starting at line 1
The warning comes from the unterminated fixture (K="unterminated). It is the only sign that a line was rejected, and it goes to stderr, so it is easy to miss in a script or CI log.
results/python-dotenv.json (shown compactly; the file in the repo is indented):
{
"bare": { "BARE": "" },
"bom": { "\ufeffK": "v" },
"crlf": { "K": "v", "L": "w" },
"dup": { "K": "2" },
"empty_comment": { "API_KEY": "# fill this in later" },
"empty_comment2": { "EMPTY_C2": "#c" },
"empty_file": {},
"escapes": { "A": "a\nb", "B": "a\\nb" },
"expand": { "HOST": "db", "URL": "db:5432" },
"export": { "KEY": "v" },
"hash_in_value": { "HASH": "Tr0ub4dor#3" },
"hash_start": { "COLOR": "#ff0000" },
"multiline": { "PEM": "-----BEGIN\nabc\n-----END" },
"spaces": { "K2": "v", "KEY": "v" },
"unterminated": { "L": "next" },
"value_comment": { "V_C": "x" }
}
What to notice:
-
empty_comment: the value is the comment text, not an empty string. -
bom: the key is\ufeffK, notK. A lookup forKfails, even though the file looks fine in an editor. -
unterminated:Kis gone, with only a log line. OnlyLsurvives. -
expand:${HOST}was resolved todbby default. -
escapes:\nbecomes a real newline in double quotes and stays literal in single quotes. -
dup: the last value wins.
Node: dotenv, with and without dotenv-expand
Expansion is a separate package in the Node world, so we report both. Check your Node version, create a package.json, and install both packages at exact versions:
user@host:~/dotenv-difference$ node --version
v22.22.1
user@host:~/dotenv-difference$ npm init -y
user@host:~/dotenv-difference$ npm install --save-exact dotenv@18.0.9 dotenv-expand@12.0.1
added 3 packages, and audited 4 packages in 424ms
user@host:~/dotenv-difference$ echo "node_modules/" >> .gitignore
Pinning matters. Results depend on the parser version, and --save-exact stops a later npm install from silently moving to a newer release.
// readers/node_dotenv_all.js
// Usage: node readers/node_dotenv_all.js <file-or-dir> [--out results/node-dotenv.json]
const fs = require("fs");
const path = require("path");
const dotenv = require("dotenv");
const { expand } = require("dotenv-expand");
function read(file) {
try {
const plain = dotenv.parse(fs.readFileSync(file)); // pass the raw Buffer
// processEnv: {} so expansion can't see the real environment
const expanded = expand({ parsed: { ...plain }, processEnv: {} }).parsed;
return { plain, expanded };
} catch (e) {
return { error: `${e.name}: ${e.message}` };
}
}
// Stable key order, so the file diffs cleanly in git
function sortKeys(value) {
if (Array.isArray(value)) return value.map(sortKeys);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.keys(value).sort().map((k) => [k, sortKeys(value[k])])
);
}
return value;
}
const args = process.argv.slice(2);
const outIdx = args.indexOf("--out");
const out = outIdx !== -1 ? args[outIdx + 1] : null;
const target = args.find((a, i) => !a.startsWith("--") && i !== outIdx + 1);
if (!target) {
console.error("usage: node readers/node_dotenv_all.js <file-or-dir> [--out file.json]");
process.exit(2);
}
let result;
if (fs.statSync(target).isDirectory()) {
result = {};
for (const f of fs.readdirSync(target).filter((f) => f.endsWith(".env")).sort()) {
result[path.basename(f, ".env")] = read(path.join(target, f));
}
} else {
result = read(target);
}
const text = JSON.stringify(sortKeys(result), null, 2) + "\n";
if (out) {
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, text, "utf8");
} else {
process.stdout.write(text);
}
user@host:~/dotenv-difference$ node readers/node_dotenv_all.js cases/empty_comment.env
{ "expanded": { "API_KEY": "" }, "plain": { "API_KEY": "" } }
user@host:~/dotenv-difference$ node readers/node_dotenv_all.js cases/ --out results/node-dotenv.json
Nothing is printed for the whole-directory run, and no warnings appear, even for the malformed unterminated fixture. Node's parser accepts it silently.
The output has two objects per case. plain is what dotenv alone returns. expanded is the result after dotenv-expand resolves ${VAR} references. They differ only on the expand fixture:
"expand": {
"expanded": { "HOST": "db", "URL": "db:5432" },
"plain": { "HOST": "db", "URL": "${HOST}:5432" }
}
Bash: source
Bash is different: it executes the file instead of parsing it. We run it in an empty environment (env -i), compare the environment before and after, and report what changed, plus the exit code and any error message.
Warning:
sourceexecutes whatever is in the file. These fixtures are harmless, but a real.envcan contain$(...)or backticks. Never run this on a file you don't trust.
#!/usr/bin/env bash
# readers/bash_source.sh
# Usage: readers/bash_source.sh <file-or-dir> (prints JSON on stdout)
# WARNING: this EXECUTES each file with bash. Only run it on fixtures you trust.
set -u
# Escape a string for use inside a JSON "..." value
json_escape() {
local s=$1
s=${s//\\/\\\\}
s=${s//\"/\\\"}
s=${s//$'\n'/\\n}
s=${s//$'\r'/\\r}
s=${s//$'\t'/\\t}
printf '%s' "$s"
}
# Load NUL-separated KEY=VALUE records from stdin into the array named $1
load_env() {
local -n _arr=$1
local kv
while IFS= read -r -d '' kv; do
[[ $kv == *=* ]] && _arr[${kv%%=*}]=${kv#*=}
done
}
# Variables bash sets by itself, plus our helper variables
declare -A IGNORE=([SHLVL]=1 [_]=1 [PWD]=1 [OLDPWD]=1 [rc]=1 [__RC__]=1)
# Baseline: what an empty-environment bash already has
declare -A BASE=()
load_env BASE < <(env -i bash -c 'env -0')
read_one() {
local file=$1 errfile err key rc=0 first=1
declare -A after=()
errfile=$(mktemp)
# Run in an empty environment; set -a exports everything the file assigns.
load_env after < <(
env -i bash -c 'set -a; source "$1"; rc=$?; set +a; env -0; printf "__RC__=%s\0" "$rc"' _ "$file" 2>"$errfile"
)
rc=${after[__RC__]:-?}
err=$(<"$errfile"); rm -f "$errfile"
printf '{\n "exit": %s,\n' "$rc"
[[ -n $err ]] && printf ' "stderr": "%s",\n' "$(json_escape "$err")"
printf ' "values": {'
while IFS= read -r key; do
[[ -z $key || -n ${IGNORE[$key]:-} ]] && continue
[[ ${BASE[$key]-__unset__} == "${after[$key]}" ]] && continue
(( first )) || printf ','
first=0
printf '\n "%s": "%s"' "$(json_escape "$key")" "$(json_escape "${after[$key]}")"
done < <(printf '%s\n' "${!after[@]}" | sort)
(( first )) && printf '}\n}' || printf '\n }\n}'
}
target=${1:-}
[[ -z $target ]] && { echo "usage: $0 <file-or-dir>" >&2; exit 2; }
target=${target%/} # avoid "cases//x.env" in error messages
if [[ -d $target ]]; then
printf '{'
sep=''
for f in "$target"/*.env; do
name=$(basename "$f" .env)
printf '%s\n "%s": ' "$sep" "$name"
read_one "$f" | sed '2,$s/^/ /'
sep=','
done
printf '\n}\n'
else
read_one "$target"; printf '\n'
fi
Bash 4.3 or newer is required (the script uses namerefs). Make it executable and run it:
user@host:~/dotenv-difference$ chmod +x readers/bash_source.sh
user@host:~/dotenv-difference$ ./readers/bash_source.sh cases/spaces.env
{
"exit": 0,
"stderr": "cases/spaces.env: line 1: KEY: command not found",
"values": {
"K2": "v"
}
}
user@host:~/dotenv-difference$ ./readers/bash_source.sh cases/ > results/bash-source.json
In spaces.env, KEY = v is run as a command named KEY, so it fails. The next line, K2=v, still loads.
Docker: Compose and docker run --env-file
Docker has two separate parsers for .env files, and they don't behave the same:
-
Docker Compose reads the file through
env_file:in a Compose file. -
docker run --env-fileuses a different, simpler parser.
The adapter loads each fixture through one of them, starts a throwaway container, and prints the environment the container received. That is the value your app would see. It runs env -0, so multi-line values survive, and subtracts the variables the image sets itself (PATH, HOME, and so on).
#!/usr/bin/env bash
# readers/docker_env.sh
# Usage: readers/docker_env.sh <compose|run> <file-or-dir> (prints JSON on stdout)
# compose : Docker Compose reads the file via env_file, then we print the container's env
# run : `docker run --env-file` reads the file, then we print the container's env
# Needs: docker (and the compose plugin for "compose"). The image is pulled on first use.
set -u
IMAGE=${IMAGE:-alpine:3.20}
mode=${1:-}; target=${2:-}
if [[ $mode != compose && $mode != run || -z $target ]]; then
echo "usage: $0 <compose|run> <file-or-dir>" >&2; exit 2
fi
command -v docker >/dev/null || { echo "docker not found" >&2; exit 2; }
target=${target%/}
json_escape() {
local s=$1
s=${s//\\/\\\\}; s=${s//\"/\\\"}
s=${s//$'\n'/\\n}; s=${s//$'\r'/\\r}; s=${s//$'\t'/\\t}
printf '%s' "$s"
}
load_env() {
local -n _arr=$1
local kv
while IFS= read -r -d '' kv; do
[[ $kv == *=* ]] && _arr[${kv%%=*}]=${kv#*=}
done
}
# Variables the image/container sets by itself (HOSTNAME changes on every run)
declare -A IGNORE=([HOSTNAME]=1 [__RC__]=1)
declare -A BASE=()
load_env BASE < <(docker run --rm "$IMAGE" env -0)
# Run one fixture through the chosen reader; prints "env -0" of the container.
reader_cmd() {
local file=$1
if [[ $mode == run ]]; then
docker run --rm --env-file "$file" "$IMAGE" env -0
else
local abs proj; abs=$(realpath "$file"); proj=$(mktemp -d)
cat > "$proj/compose.yml" <<YAML
services:
t:
image: $IMAGE
network_mode: none
env_file: ["$abs"]
YAML
docker compose -p dotenv_diff -f "$proj/compose.yml" run --rm --no-deps -T t env -0
local rc=$?
rm -rf "$proj"
return $rc
fi
}
read_one() {
local file=$1 errfile err key rc first=1 abs
declare -A after=()
abs=$(realpath "$file"); errfile=$(mktemp)
load_env after < <( { reader_cmd "$file" 2>"$errfile"; printf '__RC__=%s\0' "$?"; } )
rc=${after[__RC__]:-?}
err=$(<"$errfile"); rm -f "$errfile"
err=${err//"$abs"/$file} # keep absolute paths (and usernames) out of the output
err=${err//"$HOME"/\~}
# Drop Compose's progress lines: they contain a random container name, which would
# make the results file change on every run
err=$(grep -v -E '^ ?(Container|Network|Volume) .* (Creating|Created|Starting|Started|Removing|Removed|Running|Waiting|Healthy) ?$' <<<"$err")
printf '{\n "exit": %s,\n' "$rc"
[[ -n $err ]] && printf ' "stderr": "%s",\n' "$(json_escape "$err")"
printf ' "values": {'
while IFS= read -r key; do
[[ -z $key || -n ${IGNORE[$key]:-} ]] && continue
[[ ${BASE[$key]-__unset__} == "${after[$key]}" ]] && continue
(( first )) || printf ','
first=0
printf '\n "%s": "%s"' "$(json_escape "$key")" "$(json_escape "${after[$key]}")"
done < <(printf '%s\n' "${!after[@]}" | sort)
(( first )) && printf '}\n}' || printf '\n }\n}'
}
if [[ -d $target ]]; then
printf '{'
sep=''
for f in "$target"/*.env; do
printf '%s\n "%s": ' "$sep" "$(basename "$f" .env)"
read_one "$f" | sed '2,$s/^/ /'
sep=','
done
printf '\n}\n'
else
read_one "$target"; printf '\n'
fi
Check your versions, make the script executable, and run the line the whole article is about through both parsers:
user@host:~/dotenv-difference$ docker --version
Docker version 29.6.2, build dfc4efb
user@host:~/dotenv-difference$ docker compose version
Docker Compose version v5.3.1
user@host:~/dotenv-difference$ chmod +x readers/docker_env.sh
user@host:~/dotenv-difference$ ./readers/docker_env.sh compose cases/empty_comment.env
{
"exit": 0,
"values": {
"API_KEY": "# fill this in later"
}
}
user@host:~/dotenv-difference$ ./readers/docker_env.sh run cases/empty_comment.env
{
"exit": 0,
"values": {
"API_KEY": " # fill this in later"
}
}
Same file, same line, two different values. Compose keeps # fill this in later. docker run --env-file keeps the three leading spaces too. (The first run pulls the alpine:3.20 image, so it takes a few seconds longer.)
To see what Compose resolved without starting a container:
user@host:~/dotenv-difference$ docker compose -f - config --format json <<EOF | jq '.services.t.environment'
services:
t:
image: alpine:3.20
env_file: ["$PWD/cases/empty_comment.env"]
EOF
{
"API_KEY": "# fill this in later"
}
config and the container agree. Now every fixture, through both parsers:
user@host:~/dotenv-difference$ ./readers/docker_env.sh compose cases/ > results/compose.json
user@host:~/dotenv-difference$ ./readers/docker_env.sh run cases/ > results/docker-run.json
Each fixture starts one container, so expect it to take a minute or so.
4. Comparing the readers automatically
Six JSON files are hard to compare by eye, so a small script loads them and builds the table. Because it reads the result files, the table can't drift from the data. It skips any reader whose results file doesn't exist yet, so it also works with just the first few adapters.
#!/usr/bin/env python3
"""Build the reader-comparison table from results/*.json.
Usage:
python3 compare.py # only the cases where readers differ
python3 compare.py --all # every case
python3 compare.py --cases cases/ # label rows with the fixture's content
"""
import argparse, json, sys
from pathlib import Path
# column name -> (results file, how to pull the values out of one case entry,
# whether the entry has an "exit" status to flag)
COLUMNS = {
"python-dotenv": ("python-dotenv.json", lambda e: e, False),
"Node dotenv": ("node-dotenv.json", lambda e: e["plain"], False),
"Node + expand": ("node-dotenv.json", lambda e: e["expanded"], False),
"bash `source`": ("bash-source.json", lambda e: e["values"], True),
"Compose": ("compose.json", lambda e: e["values"], True),
"`docker run --env-file`": ("docker-run.json", lambda e: e["values"], True),
}
def load(results_dir):
files = {}
for fname, _, _ in COLUMNS.values():
if fname not in files:
files[fname] = json.loads((results_dir / fname).read_text(encoding="utf-8"))
return files
def values_of(files, col, case):
fname, pick, _ = COLUMNS[col]
return pick(files[fname][case])
def failed(files, col, case):
fname, _, has_exit = COLUMNS[col]
return has_exit and files[fname][case]["exit"] != 0
def vis(s):
# Make invisible characters visible (CR, LF, BOM) and escape quotes
return json.dumps(s)[1:-1]
def cell(values, is_failed):
if values:
text = ", ".join(f'{vis(k)}="{vis(v)}"' for k, v in values.items())
else:
text = "(nothing set)"
text = text.replace("|", "\\|")
if is_failed:
text += " ⚠ error"
return f"`{text}`"
def label(cases_dir, case):
if not cases_dir:
return f"`{case}`"
raw = (cases_dir / f"{case}.env").read_bytes().decode("utf-8", errors="replace")
raw = raw.replace("\ufeff", "<BOM>").replace("\r", "<CR>").rstrip("\n")
raw = raw.replace("\n", " ⏎ ") or "(empty file)"
return f"`{raw}`".replace("|", "\\|")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--results", default="results")
ap.add_argument("--cases")
ap.add_argument("--all", action="store_true")
a = ap.parse_args()
# Skip readers whose results file doesn't exist yet
for name in [n for n, (f, _, _) in COLUMNS.items() if not (Path(a.results) / f).exists()]:
print(f"note: skipping {name} (no results file yet)", file=sys.stderr)
del COLUMNS[name]
files = load(Path(a.results))
cases = sorted(files["python-dotenv.json"])
cases_dir = Path(a.cases) if a.cases else None
rows, agree = [], []
for case in cases:
vals = {c: values_of(files, c, case) for c in COLUMNS}
fails = {c: failed(files, c, case) for c in COLUMNS}
signature = {json.dumps(v, sort_keys=True) for v in vals.values()}
differs = len(signature) > 1 or any(fails.values())
row = [label(cases_dir, case)] + [cell(vals[c], fails[c]) for c in COLUMNS]
(rows if (differs or a.all) else agree).append((case, row, differs))
print("| Fixture | " + " | ".join(COLUMNS) + " |")
print("|---|" + "---|" * len(COLUMNS))
for _, row, _ in rows:
print("| " + " | ".join(row) + " |")
n_diff = sum(1 for _, _, d in rows if d)
print(f"\n{n_diff} of {len(cases)} fixtures give different results across readers.")
if agree:
print("Identical everywhere: " + ", ".join(f"`{c}`" for c, _, _ in agree) + ".")
if __name__ == "__main__":
main()
user@host:~/dotenv-difference$ python3 compare.py --cases cases/ > results/comparison.md
A fixture counts as "different" if any two columns disagree, or if a reader exits with an error. Pass --all to include the fixtures where every reader agrees.
Regenerating everything
Every step above is a single command, so one script reruns the whole pipeline:
#!/usr/bin/env bash
# run_all.sh - regenerate every fixture, result file and the comparison table
# Needs: the Python venv active, `npm install` done, Docker running.
set -euo pipefail
cd "$(dirname "$0")"
python3 make_cases.py
mkdir -p results
python3 readers/py_dotenv_all.py cases/ --out results/python-dotenv.json
node readers/node_dotenv_all.js cases/ --out results/node-dotenv.json
./readers/bash_source.sh cases/ > results/bash-source.json
./readers/docker_env.sh compose cases/ > results/compose.json
./readers/docker_env.sh run cases/ > results/docker-run.json
python3 compare.py --cases cases/ > results/comparison.md
Commit results/*.json and results/comparison.md. When you bump a dependency, rerun and look at the diff. A changed cell means a loader changed behavior, and you find out in code review instead of in production.
Extending the harness
Every reader is a small adapter that prints {"exit": ..., "values": {...}} as JSON, and compare.py adds a column for each new results file. godotenv (godotenv.Read), dotenvy (dotenvy::from_path_iter) and Ruby dotenv (Dotenv.parse) follow the same pattern, but they were not measured for this post. Pull requests with those adapters are welcome.
5. Results
Six readers, seven columns (Node appears twice, with and without dotenv-expand), sixteen fixtures:
| Fixture | python-dotenv | Node dotenv | Node + expand | bash source
|
Compose | docker run --env-file |
|---|---|---|---|---|---|---|
<BOM>K=v |
\ufeffK="v" |
K="v" |
K="v" |
(nothing set) ⚠ error |
K="v" |
K="v" |
K=v<CR> ⏎ L=w<CR> |
K="v", L="w" |
K="v", L="w" |
K="v", L="w" |
K="v\r", L="w\r" |
K="v", L="w" |
K="v", L="w" |
API_KEY= # fill this in later |
API_KEY="# fill this in later" |
API_KEY="" |
API_KEY="" |
API_KEY="" |
API_KEY="# fill this in later" |
API_KEY=" # fill this in later" |
EMPTY_C2= #c |
EMPTY_C2="#c" |
EMPTY_C2="" |
EMPTY_C2="" |
EMPTY_C2="" |
EMPTY_C2="#c" |
EMPTY_C2=" #c" |
A="a\nb" ⏎ B='a\nb' |
A="a\nb", B="a\\nb" |
A="a\nb", B="a\\nb" |
A="a\nb", B="a\\nb" |
A="a\\nb", B="a\\nb" |
A="a\nb", B="a\\nb" |
A="\"a\\nb\"", B="'a\\nb'" |
HOST=db ⏎ URL=${HOST}:5432 |
HOST="db", URL="db:5432" |
HOST="db", URL="${HOST}:5432" |
HOST="db", URL="db:5432" |
HOST="db", URL="db:5432" |
HOST="db", URL="db:5432" |
HOST="db", URL="${HOST}:5432" |
export KEY=v |
KEY="v" |
KEY="v" |
KEY="v" |
KEY="v" |
KEY="v" |
(nothing set) ⚠ error |
HASH=Tr0ub4dor#3 |
HASH="Tr0ub4dor#3" |
HASH="Tr0ub4dor" |
HASH="Tr0ub4dor" |
HASH="Tr0ub4dor#3" |
HASH="Tr0ub4dor#3" |
HASH="Tr0ub4dor#3" |
COLOR=#ff0000 |
COLOR="#ff0000" |
COLOR="" |
COLOR="" |
COLOR="#ff0000" |
COLOR="#ff0000" |
COLOR="#ff0000" |
PEM="-----BEGIN ⏎ abc ⏎ -----END" |
PEM="-----BEGIN\nabc\n-----END" |
PEM="-----BEGIN\nabc\n-----END" |
PEM="-----BEGIN\nabc\n-----END" |
PEM="-----BEGIN\nabc\n-----END" |
PEM="-----BEGIN\nabc\n-----END" |
PEM="\"-----BEGIN" |
KEY = v ⏎ K2=v |
K2="v", KEY="v" |
K2="v", KEY="v" |
K2="v", KEY="v" |
K2="v" |
K2="v", KEY="v" |
(nothing set) ⚠ error |
K="unterminated ⏎ L=next |
L="next" |
K="\"unterminated", L="next" |
K="\"unterminated", L="next" |
(nothing set) ⚠ error |
(nothing set) ⚠ error |
K="\"unterminated", L="next" |
V_C=x # c |
V_C="x" |
V_C="x" |
V_C="x" |
V_C="x" |
V_C="x" |
V_C="x # c" |
13 of 16 fixtures give different results. Identical everywhere: bare, dup, empty_file.
How to read the cells: \n is a real newline, \\n is a literal backslash followed by n, \r is a carriage return, and \ufeff is the byte order mark.
6. Observations
1. No two readers agree on everything. Counting differing fixtures out of 16: python-dotenv vs Node, 7; python-dotenv vs bash, 7; Compose vs docker run --env-file, 9. Compose vs python-dotenv is the closest pair, at 2. Picking a different pair doesn't make the problem go away.
2. The empty-value-plus-comment line splits the readers three ways. For API_KEY= # fill this in later:
Value of API_KEY
|
Readers |
|---|---|
# fill this in later |
python-dotenv, Compose |
# fill this in later (leading spaces kept) |
docker run --env-file |
"" (empty) |
Node dotenv, bash |
None of these is the value the author meant. For Docker users the failure is worse, because both Docker parsers turn the comment into the secret.
3. Node cuts at any #, silently. The password Tr0ub4dor#3 becomes Tr0ub4dor, and the colour #ff0000 becomes an empty string. No warning, no error. Every other reader keeps both intact.
4. Compose behaves like python-dotenv. The two agree on 14 of 16 fixtures, including the comment, escape, expansion and export cases. They differ only on the BOM (Compose strips it, python-dotenv keeps it in the key name) and on the unterminated quote (Compose fails, python-dotenv drops the line). For any tool that has to pick a default dialect, that is a useful result: matching Compose also means matching most of what Python apps see.
5. docker run --env-file is a line splitter, not a dotenv parser. It takes everything after the first = literally:
- quotes are kept:
A="a\nb"becomes the value"a\nb"including the quotation marks; - inline comments are not recognised:
V_C=x # ckeepsx # c; - there is no variable expansion:
${HOST}:5432stays as text; - whitespace after
=is preserved, which is where the leading spaces in observation 2 come from.
6. Multi-line values break silently in docker run --env-file. The PEM fixture becomes "-----BEGIN, the first line only. The continuation lines contain no =, so Docker appears to treat them as names of host variables to pass through, and since none are set, they vanish with no error. A private key would be truncated without any message.
7. docker run --env-file rejects whole files for common syntax. export KEY=v and KEY = v each make it exit with an error and load nothing, so even the valid K2=v line is lost. This matters because export KEY=v is a normal way to write a file that is also meant to be sourced by a shell. The file that works for your shell and for Compose fails for docker run.
8. Expansion depends on which package you installed. ${HOST} is resolved by python-dotenv, bash and Compose by default, but plain Node dotenv and docker run --env-file leave it as text. Adding dotenv-expand changes the Node answer, so "Node" alone isn't one result.
9. Some differences are invisible. CRLF gives bash values a trailing \r that prints like nothing. A BOM makes python-dotenv's first key \ufeffK, so a lookup for K fails even though the file looks right in an editor. Both Docker parsers and Node handle both cleanly.
10. The readers fail in different ways on a malformed line. For the unterminated quote:
| Reader | What happens |
|---|---|
| python-dotenv | drops K, keeps L, logs a warning to stderr |
| Node dotenv | keeps K with the stray quote in its value, keeps L, no warning |
docker run --env-file |
same as Node: keeps K with the stray quote, keeps L, no warning |
| Compose | rejects the whole file, sets nothing |
| bash | rejects the whole file (exit 2), sets nothing |
Only python-dotenv, Compose and bash give any signal that something went wrong, and python's is a log line that is easy to miss.
11. Bash is not a dotenv parser, so its disagreements are real traps. It runs the file as a script, so its results come from shell grammar, not dotenv rules:
-
API_KEY= # fill this in lateris an empty assignment followed by a shell comment, so the value is empty. Node reaches the same value by cutting at any#. The shell only treats#as a comment at the start of a word, which is why it keepsTr0ub4dor#3and#ff0000. - Spaces around
=turn the line into a command.KEY = vruns a program calledKEY, which fails, and the file keeps going. - A BOM becomes part of the command name, so
Kis never set (exit 127). - An unterminated quote loses the whole file, including the valid
L=nextline. - Double-quoted
\nstays literal, and${HOST}resolves with no extra package, as does anything else the shell understands, such as$(...).
If anyone on a team loads config with source .env, they are using another dialect, and source also executes any command substitution in the file.
12. The agreement is narrow. Bare keys, duplicate keys (last wins) and an empty file come out the same everywhere. Even a trailing comment after a normal value (V_C=x # c) and a quoted multi-line value split the Docker parser from the rest.
What this means
You can't pick a winner, because there's no specification to check against. The practical rules from the data:
- put comments on their own line, not after an empty value;
- quote any value that contains
#,$or spaces, but remember thatdocker run --env-filewill keep the quotes; - avoid
export, spaces around=,${VAR}references and multi-line values in any file that is used withdocker run --env-file; - keep files UTF-8 without a BOM and with LF line endings;
- never assume that two tools reading the same
.envsee the same values, and treat "it works withdocker compose" as no evidence that it works withdocker run.
7. Why this matters in practice
The disagreement becomes a problem when a tool acts on a value another tool will read differently:
- A validator that counts
API_KEY= # noteas set won't report the missing secret. - A converter or uploader (to JSON, Kubernetes, a cloud secret store) can ship
# fill this in lateras the literal secret. - A template engine can paste the comment into generated config.
- The app itself may read an empty value while your tooling read a string, so the tool and the app disagree and the author never finds out.
- A file that works with
docker composecan fail or be truncated withdocker run --env-file, so two ways of starting the same container give different configuration.
Which reader is "right" is a poor question, because there's no spec. The useful question is: does any plausible reader disagree with this line? That can be tested, and the harness above does exactly that.
8. Bonus: differential fuzzing
A hand-written corpus only finds the cases you thought of. A fuzzer finds the rest. Generate short random lines from the characters that matter, run both readers on each, and report any line where the results differ.
The fuzzer below compares python-dotenv and Node dotenv. It writes nothing to disk (python-dotenv reads from an in-memory stream, and all lines go to Node in one batch), silences python-dotenv's log output, and sorts the results into classes so the interesting ones stand out.
#!/usr/bin/env python3
"""Differential fuzzing: random one-line .env files through python-dotenv and Node dotenv.
Usage: python3 fuzz.py [seed] [count] (defaults: seed 0, 2000 lines)
"""
import io, json, logging, random, subprocess, sys
from collections import Counter
from dotenv import dotenv_values
# python-dotenv logs "could not parse statement" for every malformed line; silence it
logging.getLogger("dotenv.main").setLevel(logging.CRITICAL)
ALPHABET = ["K", "=", " ", "#", "'", '"', "$", "{", "}", "\\", "v", "1", "export "]
NODE_BATCH = """
const d = require('dotenv');
const lines = JSON.parse(require('fs').readFileSync(0, 'utf8'));
console.log(JSON.stringify(lines.map(l => d.parse(Buffer.from(l)))));
"""
def gen():
n = random.randint(2, 10)
return "".join(random.choice(ALPHABET) for _ in range(n)) + "\n"
def py(line):
try:
return dict(dotenv_values(stream=io.StringIO(line)))
except Exception as e:
return {"error": type(e).__name__}
def node_all(lines):
p = subprocess.run(["node", "-e", NODE_BATCH], input=json.dumps(lines),
capture_output=True, text=True, check=True)
return json.loads(p.stdout)
def features(line):
f = []
if "#" in line: f.append("#")
if "$" in line or "{" in line: f.append("$ or {")
if '"' in line: f.append('"')
if "'" in line: f.append("'")
if "\\" in line: f.append("backslash")
if line.startswith("export ") or " export " in line: f.append("export")
return f or ["(none of these)"]
def main():
seed = int(sys.argv[1]) if len(sys.argv) > 1 else 0
count = int(sys.argv[2]) if len(sys.argv) > 2 else 2000
random.seed(seed)
lines = sorted({gen() for _ in range(count)}) # unique lines only
node = node_all(lines)
found = {}
for line, n in zip(lines, node):
p = py(line)
if json.dumps(p, sort_keys=True) != json.dumps(n, sort_keys=True):
found[line] = {"python": p, "node": n}
# Lines with no "=" are a class of their own: python-dotenv reports the bare name
# with value None, Node ignores the line. Count them, then look at everything else.
no_eq = {l: r for l, r in found.items() if "=" not in l}
found = {l: r for l, r in found.items() if "=" in l}
print(f"seed={seed}: {len(lines)} unique lines, {len(no_eq) + len(found)} disagree")
print(f" {len(no_eq)} have no '=' (python-dotenv: name -> None, Node: line ignored)")
print(f" {len(found)} contain '=' and still disagree")
print("\nShortest disagreements among lines with '=':")
for line, res in sorted(found.items(), key=lambda kv: (len(kv[0]), kv[0]))[:25]:
print(f" {line!r:22} python={json.dumps(res['python'])} node={json.dumps(res['node'])}")
c = Counter(ft for line in found for ft in features(line))
print("\nDisagreeing lines with '=' that contain each feature:")
for ft, k in c.most_common():
print(f" {ft:16} {k:5} ({100 * k / len(found):.0f}%)")
if __name__ == "__main__":
main()
A note on the first version of the output
The first version of this fuzzer printed one python-dotenv could not parse statement starting at line 1 warning per malformed line, and with random input that is hundreds of lines. They flood the terminal and push the actual results off the screen. That is why the version above silences the dotenv.main logger. The warnings are useful when you hit one in real life (they are the only signal python-dotenv gives), and useless as a fuzzing log.
Results
Run with the default seed and 2,000 draws:
(.venv) user@host:~/dotenv-difference$ python3 fuzz.py
seed=0: 1902 unique lines, 965 disagree
628 have no '=' (python-dotenv: name -> None, Node: line ignored)
337 contain '=' and still disagree
Shortest disagreements among lines with '=':
'"=\n' python={"\"": ""} node={}
'$=\n' python={"$": ""} node={}
'}=\n' python={"}": ""} node={}
'$=1\n' python={"$": "1"} node={}
'K#=\n' python={"K": null} node={}
'K="\n' python={} node={"K": "\""}
'K=#\n' python={"K": "#"} node={"K": ""}
' 1=#\n' python={"1": "#"} node={"1": ""}
'}==\n' python={"}": "="} node={}
...
Disagreeing lines with '=' that contain each feature:
$ or { 215 (64%)
backslash 147 (44%)
" 140 (42%)
# 134 (40%)
' 105 (31%)
export 45 (13%)
(none of these) 10 (3%)
(Output shortened here. The feature counts overlap, because a line can contain several of these characters.)
Scaling up to 20,000 draws (17,047 unique lines) gives the full picture. Nearly half of all random one-line files are read differently:
| Class | Lines | Share of disagreements |
|---|---|---|
No = in the line |
5,183 | 63% |
| Name grammar (python-dotenv accepts a name Node rejects, or the reverse) | 2,755 | 33% |
| Value differences with a valid name | 320 | 4% |
| Total disagreeing | 8,258 | 48% of 17,047 |
Exact counts depend on the random generator and on the Python version, so rerun with the same seed to reproduce them. The examples below come from this larger run.
What the fuzzer found that nobody listed
None of these were in the corpus.
1. Lines without =. Given a line that is just K, python-dotenv returns {"K": null}: the name exists, with no value. Node ignores the line. For tools that count keys, this is a different answer to "is K defined?" before any value is even involved. It is the largest class by far.
2. Name grammar. Node only accepts names made of letters, digits, _, . and -. python-dotenv accepts nearly anything before the =, so $=1, }= and "= define variables in Python and are skipped silently in Node. Real names rarely look like this, but it shows that the two readers don't share a definition of a variable name, and a typo can be a valid name in one and a dropped line in the other. A variant: K#= gives python-dotenv a key K with no value (the #= appears to be read as a comment), and Node ignores it.
3. export =v. Node treats export as the variable name and sets {"export": "v"}. python-dotenv rejects the line and sets nothing. A stray space between export and the name turns a statement into a variable called export in one reader.
4. Unterminated single quotes. The corpus had the double-quote case. The fuzzer shows the single-quote case behaves the same way: K=' is dropped entirely by python-dotenv, and Node keeps the stray ' as the value.
5. # in new places. The corpus covered # at the start of a value and in the middle. The fuzzer adds the cases where it follows a quote or a brace, such as 1=v# (python keeps v#, Node keeps v) and v="#= (python drops the line, Node keeps "). Same root cause as observation 3, more places for it to bite.
The disagreements that do involve a valid name and a value (the 4% row) are almost all variations on two themes already in the table: # handling and unterminated quotes. That is a good sign for the hand-written corpus: it covers the common axes, and the fuzzer's extra finds are in the name grammar and the no-= lines.
Tips for making a fuzzer useful
-
Separate the noise. One trivial class (here, no
=) can be two thirds of the output. Split it out so the interesting disagreements are visible. - Show the shortest cases first. A three-character disagreement is a lesson. A twelve-character one is noise.
-
Bias the alphabet toward the divergence axes you already know:
#, quotes, spaces,$,=. Uniform random bytes mostly produce garbage that every reader rejects the same way. - Keep a seed so a finding is reproducible, and promote interesting finds into the fixed corpus.
- Add readers incrementally. Every new reader multiplies the disagreements, which is the point.
Limits of this fuzzer
It compares two readers, and one line at a time. It doesn't cover bash, which executes its input (a random line could run a command, so don't fuzz it on a machine you care about), and it doesn't cover the Docker parsers, which need a container per input and are slow. It also can't generate multi-line values or long files. Extending it to Compose and docker run --env-file would likely find more, since those two parsers disagree on 9 of 16 fixtures.
9. Run it in CI
The harness earns its keep when it runs automatically:
# .github/workflows/dotenv-matrix.yml
name: dotenv-matrix
on:
pull_request:
paths: ["cases/**", "readers/**", "make_cases.py", "compare.py",
"run_all.sh", "requirements.txt", "package-lock.json"]
jobs:
matrix:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: pip install -r requirements.txt # python-dotenv==1.2.2, pinned
- run: npm ci
- run: ./run_all.sh
- name: Fail if the committed results are stale
run: git diff --exit-code results/
Pin every reader to an exact version, including the Docker image tag. When Dependabot or Renovate bumps one, the diff on results/ shows precisely which cells changed. That turns "did the new release change how it parses my config?" from a surprise into a reviewable diff.
Two things to check the first time it runs:
-
The results must be deterministic. The Docker adapter filters Compose's progress lines because they contain a random container name. If
git difffails on an unchanged commit, look at thestderrfields inresults/compose.jsonfirst. - Reproduce locally with the same versions. The matrix in this post was produced with Python 3.14, and CI above uses 3.12. The adapters don't depend on the Python version, but if a diff appears, check that first.
10. Takeaways
- Measure, don't recall. Even a careful write-up got python-dotenv wrong until it was actually run.
-
Treat ambiguity as something to detect. You can't make the readers agree, but you can flag lines on which they would disagree, such as
KEY=<spaces>#..., an unquoted#in a value,exportor spaces around=. -
Be strict in what you write. When a tool emits a
.envor uploads a value, quote anything containing#, spaces or$. Quoted values are where most dialects agree, with the exception ofdocker run --env-file, which keeps the quotes. -
Keep secrets and comments apart. Put the comment on its own line, or quote the value.
API_KEY=on one line and# fill this in laterabove it is read the same way by every reader we tested. -
Check which reader your deployment really uses. Compose,
docker run --env-file, your app's library and a developer'ssource .envare four different parsers. -
Fuzz what you can't list. The corpus covered the cases we knew about. The fuzzer added the name grammar, lines without
=, andexport =v.
The full matrix, the fixtures, the adapters and the fuzzer are small enough to adopt in an afternoon. Fill in the godotenv, dotenvy and Ruby columns for your own stack, and you'll know exactly where your .env files are ambiguous.
The code and every result file are in the companion repo: urwithajit9/dotenv-difference.
Top comments (0)