A .env file is a plain-text list of KEY=value lines that a program reads at startup and turns into environment variables. The idea comes from the Twelve-Factor App, which says configuration that changes between deploys belongs in the environment rather than in code; the file is a convenient way to fill that environment on a developer machine. Packages named dotenv read it in Node.js (motdotla/dotenv), Python and Go, and Docker Compose and Node.js itself have their own readers.
There is no standard for the format. Each reader has its own rules, and they disagree on comments, quotes, escapes and ${VAR} references. This guide parses one sample file with five readers, shows which value wins when the variable already exists, and lists the practices that keep the file’s secrets where they belong.
The format, line by line
Most readers agree on the core:
# A comment line
PORT=8080
export NODE_ENV=production # "export " is accepted and dropped
APP_NAME="Order service" # quotes keep spaces; the quotes are removed
GREETING='Hello\nWorld' # single quotes: \n stays as two characters
MESSAGE="Hello\nWorld" # double quotes: \n becomes a line break
CERT="-----BEGIN CERTIFICATE-----
MIIBszCCAVmgAwIBAgIUZ...
-----END CERTIFICATE-----" # a quoted value may span lines
- One assignment per line. Every value is a string:
PORT=8080gives"8080", and turning it into a number is the application’s job. - Keys are conventionally
UPPER_SNAKE_CASE. POSIX says the variables used by its standard utilities consist solely of uppercase letters, digits and underscores and do not begin with a digit, and it leaves names with lowercase letters to applications (Base Definitions, chapter 8). - Blank lines and lines that start with
#are ignored.
Everything beyond this core is where readers differ.
One file, five readers: measured differences
We parsed the file below on 2026-10-02 with dotenv 16.6.1 and 18.0.5 (identical results; the ZeroTool .env parser follows the same rules and gives the same values), Node.js 24.14.0’s built-in util.parseEnv() (the parser behind node --env-file, same result on Node 22.23.3), python-dotenv 1.2.4 (dotenv_values()), godotenv 1.5.1 (godotenv.Read()) and Docker Compose 5.1.2 (env_file: in a service, read with docker compose config). The last line ends in CRLF.
# sample.env
export EXPORTED=yes
SPACED = around equals
HASH=abc#123
HASH_SPACE=abc #123
SINGLE='line1\nline2'
DOUBLE="line1\nline2"
MULTI="first
second"
BASE=https://api.example.test
EXPANDED=${BASE}/v1
LITERAL='${BASE}/v1'
EQUALS=a=b=c
EMPTY=
PADDED= padded
ESCAPED="say \"hi\""
BACKTICK=`tick`
DUP=first
DUP=second
NOEQUALS
CRLF=value
Values are shown as JSON strings, so \n is a line break and \\n is a backslash followed by n. A dash means the key is not set.
| Key | dotenv / ZeroTool | Node parseEnv | python-dotenv | godotenv | Compose env_file |
|---|---|---|---|---|---|
| EXPORTED | "yes" | "yes" | "yes" | "yes" | "yes" |
| SPACED | "around equals" | "around equals" | "around equals" | "around equals" | "around equals" |
| HASH | "abc" | "abc" | "abc#123" | "abc#123" | "abc#123" |
| HASH_SPACE | "abc" | "abc" | "abc" | "abc" | "abc" |
| SINGLE | "line1\\nline2" | "line1\\nline2" | "line1\\nline2" | "line1\\nline2" | "line1\\nline2" |
| DOUBLE | "line1\nline2" | "line1\nline2" | "line1\nline2" | "line1\nline2" | "line1\nline2" |
| MULTI | "first\nsecond" | "first\nsecond" | "first\nsecond" | "first\nsecond" | "first\nsecond" |
| EXPANDED | "${BASE}/v1" | "${BASE}/v1" | "https://api.example.test/v1" | "https://api.example.test/v1" | "https://api.example.test/v1" |
| LITERAL | "${BASE}/v1" | "${BASE}/v1" | "https://api.example.test/v1" | "${BASE}/v1" | "${BASE}/v1" |
| EQUALS | "a=b=c" | "a=b=c" | "a=b=c" | "a=b=c" | "a=b=c" |
| PADDED | "padded" | "padded" | "padded" | "padded" | "padded" |
| ESCAPED | "say \\\"hi\\\"" | "say \\" | "say \"hi\"" | "say \"hi\\" | "say \"hi\"" |
| BACKTICK | "tick" | "tick" | "`tick`" | "`tick`" | "`tick`" |
| DUP | "second" | "second" | "second" | "second" | "second" |
| NOEQUALS | — | — | null | error | — |
| CRLF | "value" | "value" | "value" | "value" | "value" |
What the differences mean in practice:
#without a space before it. dotenv and Node cutHASH=abc#123toabc; python-dotenv, godotenv and Compose keepabc#123. The Compose documentation states its rule: an inline comment after an unquoted value must be preceded by a space. A password containing#is the usual victim. Quote it.- Escaped quotes. Only python-dotenv and Compose turn
\"into". dotenv keeps the backslashes, godotenv drops the last quote and keeps a backslash, and Node’s parser stops at the first escaped quote and returnssay \. For values that contain double quotes, such as JSON, single-quote the whole value instead:PAYLOAD='{"id":1}'gives{"id":1}in all five. ${VAR}references. dotenv and Node never expand them; python-dotenv, godotenv and Compose do. python-dotenv expanded${BASE}even inside single quotes; godotenv and Compose treat single quotes as literal, as the Compose documentation describes. With dotenv, the README points to dotenvx for expansion.- Backticks. Only dotenv and Node treat
`…`as quotes. - A line without
=. dotenv, Node and Compose skip it; python-dotenv returns the key with valueNone(andload_dotenv()sets nothing); godotenv fails the whole file withunexpected character "\n" in variable name. The godotenv values in the table come from the same file without that line. - Spaces around
=. All five acceptSPACED = around equals. A shell does not:set -a; . ./sample.envin bash 3.2 printedSPACED: command not found, and kept the carriage return in the CRLF line ($'value\r'). If a file must also be sourced by a shell, writeKEY=valuewith no spaces and quote anything else.
To find disagreements in your own file between dotenv and Node’s built-in reader, run this script next to it:
// compare-env.mjs — run: node compare-env.mjs [.env] (needs: npm install dotenv)
import { readFileSync } from 'node:fs';
import { parseEnv } from 'node:util';
import dotenv from 'dotenv';
const text = readFileSync(process.argv[2] ?? '.env', 'utf8');
const fromDotenv = dotenv.parse(text);
const fromNode = parseEnv(text);
const keys = new Set([...Object.keys(fromDotenv), ...Object.keys(fromNode)]);
for (const key of keys) {
if (fromDotenv[key] !== fromNode[key]) {
console.log(`${key}: dotenv ${JSON.stringify(fromDotenv[key])}, node ${JSON.stringify(fromNode[key])}`);
}
}
// ESCAPED: dotenv "say \\\"hi\\\"", node "say \\"
Which value wins: the file or the existing environment
A .env file usually fills in defaults, so readers do not overwrite a variable that is already set. We set PORT=3000 in the shell and loaded a file containing PORT=8080:
| Reader | Result | How to let the file win |
|---|---|---|
dotenv config() | 3000 | config({ override: true }) |
Node --env-file | 3000; the Node.js CLI documentation says “the value from the environment takes precedence” | — |
python-dotenv load_dotenv() | 3000 | load_dotenv(override=True) |
godotenv Load() | 3000 | godotenv.Overload() gave 8080 |
Compose ${PORT} with a project .env | 3000 | Unset the shell variable |
This is the most common reason for “I changed .env and nothing happened”: the old value is still exported in the shell, set by the CI system, or set by the container platform. Print the variable where the program runs before you edit the file again.
Compose reads a .env file in two different roles. The project .env next to compose.yaml supplies values for ${VAR} substitution inside the Compose file; env_file: passes variables into a container. The table above used env_file:.
Loading a .env file in Node.js, Python and Go
Node.js 20.6 or later reads the file without any package: node --env-file=.env server.js. The flag was added in v20.6.0, gained multi-line values in v20.12.0 and v21.7.0, and stopped being experimental in v22.21.0 and v24.10.0 (Node.js documentation). process.loadEnvFile() and util.parseEnv() expose the same parser. It has the escaped-quote behavior shown above.
dotenv is still the most common choice when you need override or multiple files:
import dotenv from 'dotenv';
dotenv.config({ path: ['.env.local', '.env'], quiet: true });
Since version 17.0.0 quiet defaults to false (dotenv changelog), so config() prints a line such as ◇ injected env (17) from .env; quiet: true turns it off. With several paths, the first file that sets a key wins unless override is set: with A=one in .env.local and A=two in .env, the call above sets one.
Python: load_dotenv() writes into os.environ; dotenv_values() returns a dictionary and leaves the environment alone, which is handy for tests.
from dotenv import dotenv_values, load_dotenv
load_dotenv() # does not override existing variables
config = dotenv_values(".env") # {"PORT": "8080", ...}, values are str or None
Go: godotenv.Load() sets variables that are not already set and returns an error for a malformed file, which the NOEQUALS row shows is worth checking:
if err := godotenv.Load(); err != nil {
log.Fatalf("loading .env: %v", err)
}
Keeping secrets out of Git and out of the browser
- Ignore the file before the first commit. Add
.envand.env.*.localto.gitignore, commit a.env.examplethat lists every key with a placeholder, and check withgit check-ignore -v .env. The dotenv README answers “Should I commit my.envfile?” with “No.” - If it was committed, rotate first. Deleting the file in a new commit leaves it in history. Change every credential in it, then follow GitHub’s removing sensitive data guide to rewrite history.
- Know which variables reach the browser. Front-end build tools copy some variables into the JavaScript bundle that every visitor downloads. Vite exposes only variables prefixed with
VITE_; Next.js inlines those prefixed withNEXT_PUBLIC_. Never give a secret one of these prefixes. - Prefer files over variables for production secrets in containers. The Compose secrets guide notes that environment variables are visible to all processes and can end up in logs; Compose secrets are mounted as files under
/run/secrets/<name>. - Validate at startup. Fail fast when a required key is missing or empty instead of discovering it on the first request. Schema libraries such as envalid (Node.js) and pydantic-settings (Python) do this; a ten-line check works too.
Checking a file with the ZeroTool .env parser
The .env File Parser runs in your browser and does not upload the text, which matters for a file full of credentials. It applies dotenv’s rules, so its values match the first column of the table, and it marks the lines that the other readers would read differently. For sample.env it reports:
| Line | Key | Note from the parser |
|---|---|---|
| 4 | HASH | Text after # is a comment; quote the value to keep it |
| 8 | MULTI | Multiline value (lines 8-9) |
| 14 | EMPTY | Empty value |
| 19 | DUP | Duplicate key |
| 20 | — | Missing = sign |
The table view shows each key with its final value, and Export JSON writes the parsed keys to env.json (later duplicates win, as in dotenv). It does not expand ${VAR} and does not implement the python-dotenv, godotenv or Compose rules, so use the table above to predict what those readers will do with the lines it flags.