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 on NODE_ENV or BUN_ENV), then .env
  • Later files overwrite earlier values using the override=true flag in Map.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 object
  • Bun.env – Bun-specific global
  • import.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.createNullDelimitedEnvMap builds a C-style char **envp array for POSIX execve calls
  • Map.writeWindowsEnvBlock creates a UTF-16 environment block for Windows CreateProcessW

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 Map instance initialized during startup; the OS environment is never queried again after Loader.loadProcess() completes.
  • Thread-Safe by Design: The Map is 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 Parser supports $VAR, ${VAR}, and ${VAR:-default} syntax within .env files.
  • Child Process Ready: The Map provides createNullDelimitedEnvMap and writeWindowsEnvBlock for 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →