Every process.env value is a string, so DEBUG=false is truthy
published
TL;DR
process.env is not a plain object with the values you put in it. Every value is coerced to a string on the way in and on the way out, so DEBUG=false gives you "false" — a five-character string, which is truthy. if (process.env.DEBUG) is true for false, for 0, and for no. The only falsy value the environment can hand you is the empty string. Parse the value; never test it for truthiness.
The problem
The flag reads like a boolean, so it gets used like one:
if (process.env.DEBUG) {
enableVerboseLogging()
}
Someone turns it off the obvious way, and it stays on:
$ DEBUG=false node -e "console.log(typeof process.env.DEBUG, JSON.stringify(process.env.DEBUG), Boolean(process.env.DEBUG))"
string "false" true
0 behaves the same way, which is worse, because 0 is the one value people are confident about:
$ FLAG=0 node -e "console.log(JSON.stringify(process.env.FLAG), Boolean(process.env.FLAG), Number(process.env.FLAG))"
"0" true 0
The string "0" is truthy. Number("0") is 0 and falsy. So whether the flag works depends entirely on whether the reader remembered to convert it, and both versions look correct in review.
Why it happens
process.env is backed by the operating system’s environment block, which stores bytes, not JavaScript values. Node exposes it through an object whose setter stringifies whatever you assign. That coercion is not a convenience — it is the only representation the underlying storage has.
You can watch it happen to four different types at once:
$ node -e "process.env.A=false; process.env.B=0; process.env.C=undefined; process.env.D=null; console.log(JSON.stringify([process.env.A,process.env.B,process.env.C,process.env.D]))"
["false","0","undefined","null"]
The third one is the trap that costs real time. Assigning undefined does not unset the variable and does not give you undefined back — it gives you the string "undefined", which is truthy and seven characters long. Code that clears a flag conditionally sets it instead:
process.env.API_KEY = config.apiKey // config.apiKey is undefined
if (process.env.API_KEY) { // true — the string "undefined"
authenticate(process.env.API_KEY) // sends the literal text "undefined"
}
To actually remove a variable you have to delete the key:
$ node -e "process.env.X='1'; process.env.X=undefined; console.log(JSON.stringify(process.env.X)); delete process.env.X; console.log(JSON.stringify(process.env.X))"
"undefined"
undefined
There is exactly one falsy string the environment can produce, and it is the empty one. Note that an unset variable and an empty one are distinguishable, which is the hook every correct parser hangs on:
$ EMPTY= node -e "console.log(JSON.stringify(process.env.EMPTY), JSON.stringify(process.env.NOPE), Boolean(''))"
"" undefined false
| Shell input | process.env.X | Truthy? | Number(x) |
|---|---|---|---|
X=false | "false" | yes | NaN |
X=0 | "0" | yes | 0 |
X=no | "no" | yes | NaN |
X= | "" | no | 0 |
| (unset) | undefined | no | NaN |
Four of those five are values a person would write to mean “off”. One of them works.
What to do
Parse the string into the type you actually want, in one place, and use the parsed value everywhere else.
/** Accepts the spellings people actually type; everything else is an error. */
function envFlag(name: string, fallback = false): boolean {
const raw = process.env[name]
if (raw === undefined || raw === '') return fallback
const v = raw.trim().toLowerCase()
if (['1', 'true', 'yes', 'on'].includes(v)) return true
if (['0', 'false', 'no', 'off'].includes(v)) return false
throw new Error(`${name} must be a boolean, got ${JSON.stringify(raw)}`)
}
const DEBUG = envFlag('DEBUG')
Three details in that function are load-bearing:
=== ''is checked separately fromundefined.X=in a compose file means “present but empty”, and treating it as the default is almost always what you want.??alone will not do this — the empty string is not nullish, soprocess.env.X ?? 'default'returns''.- It throws on nonsense. A typo like
DEBUG=turesilently meansfalseunder any permissive parser. Refusing to start is the cheaper failure. - It returns a real boolean, so nothing downstream has to remember the rule again.
Numbers need the same treatment, and parseInt is the wrong tool because it stops at the first non-digit — parseInt('3000abc') is 3000, and parseInt('') is NaN:
function envInt(name: string, fallback: number): number {
const raw = process.env[name]
if (raw === undefined || raw.trim() === '') return fallback
const n = Number(raw)
if (!Number.isInteger(n)) throw new Error(`${name} must be an integer, got ${JSON.stringify(raw)}`)
return n
}
Number rejects '3000abc' with NaN instead of quietly truncating it, which is what you want for a port number.
To unset a variable, delete the key rather than assigning:
delete process.env.API_KEY // correct
process.env.API_KEY = undefined // sets it to the string "undefined"
Caveats
- This is not a
dotenvbug and switching loaders will not fix it. Anything that populatesprocess.env—dotenv,node --env-file, a container runtime, systemd, a CI secret store — is writing strings, because that is what the environment block holds. The coercion is below all of them. - Schema validators solve this properly at scale.
zod’sz.coerce.boolean()will not help on its own — it applies JavaScript’sBoolean()semantics, so"false"is stilltrue. You need an explicit string-to-boolean transform there too. - Variable names are case-sensitive on POSIX and case-insensitive on Windows.
process.env.Pathandprocess.env.PATHare the same variable on Windows and different ones on Linux, which is a separate portability trap worth knowing about. - Everything above was checked on Node v26.2.0. The behaviour is long-standing and not version-specific, but the exact output is from that build.