Google "dotenv vs process.env" and you'll find a dozen articles treating them as competitors. They're not — they solve different problems and are meant to be used together. Here's the clean model.
The one-sentence answer
process.env is Node's built-in global that exposes environment variables at runtime. dotenv is an npm package that reads a .env file and pushes its keys into process.env at startup.
One exposes values. The other populates them.
What process.env actually is
Every time Node starts, it copies the shell's environment into a plain JavaScript object called process.env. No library needed — it's part of Node.
// Launch with env inline
$ DATABASE_URL=postgres://... node server.js
// Inside server.js, no imports needed:
console.log(process.env.DATABASE_URL);
// → postgres://...Key facts:
- It's a plain object — you can assign to it at runtime:
process.env.FOO = 'bar'. That mutation only lives in the current process. - Every value is a string.
process.env.PORT = 3000becomes"3000". You coerce manually:parseInt(process.env.PORT, 10). - Reading a missing key returns
undefined, not an error. That's why the missing env detector and boot-time validation matter. - It's populated from the OS process environment, which typically comes from the shell you launched from (or the
envfield your host injected).
What dotenv actually does
dotenv is a ~200-line library that reads a .env file from disk and iterates over its lines, setting each key on process.env — unless that key is already set (dotenv never overrides the existing process environment).
Pseudocode of what it does:
// Conceptually, dotenv is this:
import fs from 'node:fs';
const lines = fs.readFileSync('.env', 'utf8').split('\n');
for (const line of lines) {
if (!line || line.startsWith('#')) continue;
const [key, ...rest] = line.split('=');
if (process.env[key] !== undefined) continue; // don't override
process.env[key] = rest.join('=').trim();
}That's it. No magic. No async. No hidden cache.
Using them together
// server.js — top of the file
import 'dotenv/config'; // 1. dotenv reads .env, writes to process.env
// 2. Anywhere after, read process.env as usual
const db = process.env.DATABASE_URL;
const port = parseInt(process.env.PORT ?? '3000', 10);Import order matters. If another import at the top also reads process.env, it will see stale values if dotenv/config isn't first.
The "do I even need dotenv?" question
Node 20.6+ native
Modern Node supports the --env-file flag — no dotenv dependency needed:
node --env-file=.env server.js
// Multiple files (later overrides earlier)
node --env-file=.env --env-file=.env.local server.jsFor new projects, this is the right default. For existing projects with dotenv already installed, leave it alone — it works, it's maintained, it's 200 KB gone from your bundle either way.
Production doesn't usually need dotenv at all
Vercel, Railway, Fly, Render, AWS, Heroku — every production host injects env vars directly into process.env before your app starts. You don't ship a .env file to prod. dotenv is a local-dev convenience.
Common pattern:
if (process.env.NODE_ENV !== 'production') {
require('dotenv/config'); // only load the file in dev
}Or better — use node --env-file=.env only in your dev npm script and never load it elsewhere.
Precedence: what wins when keys collide
This trips up everyone eventually.
- Shell env wins. If your shell has
DATABASE_URL=Xexported, dotenv will not override it with whatever's in.env. This is by design. - First file wins for vanilla dotenv. Load two files, first one is kept:
dotenv.config({ path: '.env.local' }); dotenv.config()→.env.localvalues win. - Last file wins for Node native.
--env-file=.env --env-file=.env.local→ the second file overrides the first.
If that asymmetry bothers you, it should. Most teams standardize on one loader. Our env merger tool lets you preview any precedence model before going live.
Type safety — neither of them helps
Both process.env and dotenv give you string | undefined. You want types? Layer one of:
- Zod — hand-roll a schema
@t3-oss/env-nextjs— Next.js convention with built-in client/server splitenvalid— minimal, no dependenciesconvict— Mozilla-sized, good for complex config trees
Quick Zod example:
import { z } from 'zod';
import 'dotenv/config';
const env = z.object({
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().int().default(3000),
NODE_ENV: z.enum(['development', 'test', 'production']),
}).parse(process.env);
// env.PORT is now number, not string
// Missing keys crash the process at import timeThe common bugs
process.env.X is undefined in some files, defined in others
Classic ordering bug. The file where it's undefined imports before dotenv/config. Fix: import 'dotenv/config' as the first import in your entry file, or use the -r dotenv/config flag.
Works locally, breaks in prod
Production host doesn't have a .env, so dotenv has nothing to load. Your runtime expected some key that was only in .env locally. Fix: set the key in the host dashboard. Catch it before deploy with our env diff checker.
Multiline values mangled
RSA keys (BEGIN PRIVATE KEY) have newlines. dotenv v16+ supports multiline values — wrap them in double quotes. Older versions require \\n escapes that you undo at read time.
// .env
PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
MIIEvQIB...
-----END PRIVATE KEY-----"
// or for older dotenv:
PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIB...\n-----END PRIVATE KEY-----"
// And unescape at read time:
const pem = process.env.PRIVATE_KEY.replace(/\\n/g, '\n');Cheat sheet
| Task | Use |
|---|---|
| Read a value | process.env.X |
| Load a .env (modern Node) | node --env-file=.env |
| Load a .env (any Node) | import 'dotenv/config' |
| Check required keys | Zod schema + parse at boot |
| Override existing values | Shell export, or dotenv's override: true |
| Production env | Host dashboard — no file needed |
Further reading
- How to use dotenv across Node, Next.js, Python, Rails
- What is a .env file (primer)
- Browser validator — catches syntax bugs that dotenv silently ignores