How Bun Handles Environment Variables at Runtime: A Deep Dive into the Zig Implementation
Bun manages environment variables through a three-stage pipeline written in Zig that copies the native OS environment, parses .env files with variable expansion, and exposes a unified, read-only Map to JavaScript via process.env, Bun.env, and import.meta.env.
Bun's runtime treats environment variables as a first-class feature, implementing a deterministic, high-performance system that eliminates race conditions while supporting sophisticated .env file parsing. According to the oven-sh/bun source code, the entire system centers around a single Map instance defined in src/env.zig that serves as the immutable source of truth after startup.
The Three-Stage Loading Pipeline
Bun initializes its environment in a strict sequence during process startup. This ensures predictable behavior across platforms and prevents conflicts between native OS variables and file-based configuration.
Stage 1: Native Process Environment Loading
When Bun starts, Loader.loadProcess() in src/env.zig (lines 50-68) iterates over std.os.environ to copy every KEY=VALUE pair into an internal Map. The function splits each entry on the first = character, stores empty strings for keys without values, and sets a did_load_process flag to prevent re-initialization.
This stage creates the initial snapshot of the OS environment. Once complete, Bun never queries the OS environment again, ensuring consistent reads throughout the process lifetime.
Stage 2: .env File Parsing and Precedence
After loading the native environment, Loader.load() in src/env_loader.zig (lines 94-108) handles .env file processing through two distinct paths:
Automatic Loading (unless --no-env-file is passed):
loadDefaultFiles()walks a strict precedence order:.env.local,.env.{development,production,test}(based onNODE_ENVorBUN_ENV), then.env- Later files overwrite earlier values using the
override=trueflag inMap.put
Explicit Loading (via --env-file):
loadExplicitFiles()processes user-specified files in the order provided on the command line
Parsing Implementation:
The custom Parser in src/env_loader.zig supports unquoted, single-quoted, double-quoted, and back-ticked values. It handles variable expansion using $VAR or ${VAR} syntax, including the ${VAR:-default} fallback syntax for default values.
Stage 3: Runtime JavaScript Exposure
Once startup completes, the Map becomes read-only and Bun exposes three identical interfaces:
process.env– Node.js-compatible objectBun.env– Bun-specific globalimport.meta.env– ES Modules standard
These proxy reads to Loader.get(key), which returns a nullable string. For boolean checks, Loader.has(key) implements truthiness validation that ignores empty strings, "0", and "false".
Special-purpose getters like Loader.getTLSRejectUnauthorized() cache the boolean interpretation of NODE_TLS_REJECT_UNAUTHORIZED for Node.js compatibility.
Low-Level Platform Integration
The Map structure in src/env.zig provides platform-specific utilities for spawning child processes:
Map.createNullDelimitedEnvMapbuilds a C-stylechar **envparray for POSIXexecvecallsMap.writeWindowsEnvBlockcreates a UTF-16 environment block for WindowsCreateProcessW
On Windows, Bun uses bun.CaseInsensitiveASCIIStringArrayHashMap for the Map implementation, matching Node.js behavior where process.env.Path and process.env.PATH resolve identically.
Working with Bun Environment Variables
Bun supports both reading and writing environment variables, though with different semantics:
// Reading variables (returns string | undefined)
const apiKey = process.env.API_KEY;
const port = Bun.env.PORT ?? "3000";
// Writing variables updates the internal Map and OS env for child processes
process.env.DEBUG = "true";
// Boolean checking with truthiness validation
if (Bun.env.has("NODE_TLS_REJECT_UNAUTHORIZED")) {
// Only true if value is not "", "0", or "false"
}
// Internal Zig access (src/env_loader.zig)
var envMap = Loader.init(allocator);
envMap.loadProcess(); // Copy OS environment
envMap.load(); // Load .env files with precedence
const home = envMap.map.get("HOME"); // Returns ?[]const u8
Summary
- Single Source of Truth: All environment values live in one
Mapinstance initialized during startup; the OS environment is never queried again afterLoader.loadProcess()completes. - Thread-Safe by Design: The
Mapis only mutated during the startup phase, eliminating race conditions during runtime JavaScript execution. - Platform-Consistent: Windows uses case-insensitive keys via
CaseInsensitiveASCIIStringArrayHashMap, while POSIX systems respect case sensitivity. - Variable Expansion: The custom
Parsersupports$VAR,${VAR}, and${VAR:-default}syntax within.envfiles. - Child Process Ready: The
MapprovidescreateNullDelimitedEnvMapandwriteWindowsEnvBlockfor correctly passing the current environment to spawned processes.
Frequently Asked Questions
How does Bun determine which .env files to load automatically?
Bun follows a strict precedence order unless --no-env-file is specified. The loader checks NODE_ENV or BUN_ENV to select environment-specific files, loading .env.local first, then .env.{development,production,test}, and finally .env. Later files overwrite earlier values, as implemented in loadDefaultFiles() within src/env_loader.zig.
Is process.env case-sensitive on Windows?
No. On Windows, Bun uses bun.CaseInsensitiveASCIIStringArrayHashMap for the internal environment Map, making process.env.Path and process.env.PATH return the same value. This matches Node.js behavior. On Linux and macOS, environment variables remain case-sensitive.
Does Bun support variable expansion inside .env files?
Yes. The Parser in src/env_loader.zig supports shell-style variable expansion including $VAR, ${VAR}, and the ${VAR:-default} syntax for default values. This expansion occurs during the loading phase before values are stored in the internal Map.
How does Bun handle environment variable modifications for child processes?
When you assign to process.env.KEY, Bun updates the internal Map and ensures these changes propagate to child processes through platform-specific APIs. The Map.createNullDelimitedEnvMap function builds POSIX-compatible envp arrays, while Map.writeWindowsEnvBlock creates the UTF-16 environment blocks required by Windows CreateProcessW.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →