guide7 min readpublished 2026-04-19

dotenv vs process.env in Node.js: what's the actual difference?

dotenv is the library that loads a file. process.env is the global Node provides. They work together — here's where each starts and ends, with examples.

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 = 3000 becomes "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 env field 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.js

For 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.

  1. Shell env wins. If your shell has DATABASE_URL=X exported, dotenv will not override it with whatever's in .env. This is by design.
  2. First file wins for vanilla dotenv. Load two files, first one is kept: dotenv.config({ path: '.env.local' }); dotenv.config() .env.local values win.
  3. 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 split
  • envalid — minimal, no dependencies
  • convict — 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 time

The 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

TaskUse
Read a valueprocess.env.X
Load a .env (modern Node)node --env-file=.env
Load a .env (any Node)import 'dotenv/config'
Check required keysZod schema + parse at boot
Override existing valuesShell export, or dotenv's override: true
Production envHost dashboard — no file needed

Further reading

Try these tools

Related guides