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=8080 gives "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.

Keydotenv / ZeroToolNode parseEnvpython-dotenvgodotenvCompose 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——nullerror—
CRLF"value""value""value""value""value"

What the differences mean in practice:

  • # without a space before it. dotenv and Node cut HASH=abc#123 to abc; python-dotenv, godotenv and Compose keep abc#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 returns say \. 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 value None (and load_dotenv() sets nothing); godotenv fails the whole file with unexpected character "\n" in variable name. The godotenv values in the table come from the same file without that line.
  • Spaces around =. All five accept SPACED = around equals. A shell does not: set -a; . ./sample.env in bash 3.2 printed SPACED: command not found, and kept the carriage return in the CRLF line ($'value\r'). If a file must also be sourced by a shell, write KEY=value with 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:

ReaderResultHow to let the file win
dotenv config()3000config({ override: true })
Node --env-file3000; the Node.js CLI documentation says “the value from the environment takes precedence”—
python-dotenv load_dotenv()3000load_dotenv(override=True)
godotenv Load()3000godotenv.Overload() gave 8080
Compose ${PORT} with a project .env3000Unset 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 .env and .env.*.local to .gitignore, commit a .env.example that lists every key with a placeholder, and check with git check-ignore -v .env. The dotenv README answers “Should I commit my .env file?” 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 with NEXT_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:

LineKeyNote from the parser
4HASHText after # is a comment; quote the value to keep it
8MULTIMultiline value (lines 8-9)
14EMPTYEmpty value
19DUPDuplicate 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.