The .env Surprise: Why Your dotenv Configuration Has No Effect in Bun

For Node.js developers, the workflow is muscle memory: need environment variables? Install dotenv, call config(), and the variables from your .env file are ready to go. However, when you bring this habit to Bun, the newer, blazing-fast JavaScript runtime, you'll encounter a surprise—a bit of "magic" that renders your dotenv setup seemingly useless.
It's an anomaly that has stumped many developers: You have both a .env and a .env.local file. You explicitly call dotenv in your code, which should load .env. But when you run it, Bun loads the variables from .env.local instead.
This isn't a bug. It's a fundamental feature that showcases how Bun thinks differently.
The Surprising Scenario
Imagine the following simple project structure:
.env
DATABASE_URL="postgresql://default_user@db/common_database"
.env.local
DATABASE_URL="postgresql://local_user@db/my_special_database"
server.js
JavaScript
// The old habit from the Node.js world
import 'dotenv/config';
console.log(`Connecting to database using: ${process.env.DATABASE_URL}`);
Based on pure dotenv logic, the output you'd expect would come from the .env file, as dotenv.config() targets that file by default.
However, when you run bun run server.js, the output that appears in your console is:
Connecting to database using: postgresql://local_user@db/my_special_database
The value from .env.local wins. The dotenv package you imported appears to have been completely ignored. What's really going on?
Unraveling the "Magic": The Bun Runtime Acts First
The answer lies in the order of execution. Unlike Node.js, which is a "blank canvas," Bun is a "batteries-included" runtime. One of those "batteries" is a highly efficient, native environment variable loader.
Here is the actual sequence of events when you run bun run server.js:
Bun Takes Control (Before Your Code Runs): When the
bun runcommand is executed, before a single line of yourserver.jsis touched, the Bun runtime itself scans your project directory.Native Loading by Bun: Bun automatically discovers all relevant
.envfiles. It loads them in a predetermined order of precedence, where more specific files override more general ones. The rule is:.env.localalways overrides.env.process.envis Pre-populated: After this step, the globalprocess.envobject has already been populated by Bun. In our case,process.env.DATABASE_URLis already set to"postgresql://local_user@db/my_special_database".Your Code Execution Begins: Only after all this preparation is complete does Bun start executing the code inside
server.js.The Futile
dotenvCall: Your code reaches theimport 'dotenv/config'line. Thedotenvpackage runs and reads the.envfile. However, when it's about to injectDATABASE_URLintoprocess.env, it sees that the variable already exists. The default behavior ofdotenvis not to overwrite existing variables. As a result, its call has no effect.
In short, Bun has already set the stage. The dotenv package you called arrived at a party that was already over.
What This Means for You
Understanding this behavior is critical for working efficiently with Bun.
Ditch
dotenvfrom Your Bun Projects: You simply don't need it. Relying on Bun's native loader is cleaner, faster, and removes one dependency from your project.Trust Bun's Conventions: Leverage Bun's built-in
.envprecedence system. Use.envfor team-wide defaults,.env.developmentor.env.productionfor environment-specific settings, and.env.localfor your local overrides that should never be committed to Git.A New Mindset: This is a perfect example of how Bun isn't just a "faster Node.js." It's an ecosystem with its own design philosophy that aims to simplify the modern development workflow.
So, the next time you see "weird" behavior in Bun, remember that it's often not a bug, but a clever feature designed to make your life easier—even if it means unlearning a few old habits.





