Skip to content

Environment Variables

An environment variable is a named value provided to a program by the operating system.

An environment variable is a named value provided to a program by the operating system.

Programs use environment variables for configuration.

Examples:

HOME
PATH
TMPDIR
USER
EDITOR

A program may also define its own variables:

APP_PORT
DATABASE_URL
LOG_LEVEL

Environment variables are text.

If you need a number, boolean, path, or URL, you must parse the text yourself.

Viewing Environment Variables

On Linux and macOS:

echo $HOME

On Windows Command Prompt:

echo %USERNAME%

Programs inherit environment variables from the process that started them.

For example:

APP_PORT=8080 ./my_program

The program can then read:

APP_PORT

from its environment.

Reading an Environment Variable

The standard library provides access to environment variables through std.process.

A common pattern is:

const std = @import("std");

pub fn main() !void {
    const allocator = std.heap.page_allocator;

    const home = std.process.getEnvVarOwned(allocator, "HOME") catch {
        std.debug.print("HOME not set\n", .{});
        return;
    };
    defer allocator.free(home);

    std.debug.print("HOME = {s}\n", .{home});
}

This asks for the value of:

HOME

If the variable exists, the function returns an allocated string.

If the variable does not exist, the function returns an error.

Why an Allocator Is Needed

Environment variable lengths are not known at compile time.

The returned text must be stored somewhere.

That is why this function needs an allocator:

std.process.getEnvVarOwned(allocator, "HOME")

The returned memory belongs to the caller.

That is why this line matters:

defer allocator.free(home);

Without freeing, the memory would leak.

This follows Zig’s normal ownership rules:

The function allocates memory.

The caller later frees it.

Handling Missing Variables

Environment variables are optional.

A program should not assume a variable exists unless it controls the environment itself.

This pattern handles missing variables:

const value = std.process.getEnvVarOwned(allocator, "APP_PORT") catch {
    std.debug.print("APP_PORT not set\n", .{});
    return;
};

You can also provide a default value.

const port_text = std.process.getEnvVarOwned(allocator, "APP_PORT") catch {
    const default = "8080";
    std.debug.print("using default port {s}\n", .{default});
    return;
};

But remember the distinction:

A missing variable may be acceptable.

A malformed variable is usually an error.

Do not silently accept broken input.

Parsing Environment Variables

Environment variables are strings.

Suppose:

APP_PORT=8080

You still need to parse the number.

const std = @import("std");

pub fn main() !void {
    const allocator = std.heap.page_allocator;

    const port_text = std.process.getEnvVarOwned(
        allocator,
        "APP_PORT",
    ) catch {
        std.debug.print("APP_PORT not set\n", .{});
        return;
    };
    defer allocator.free(port_text);

    const port = try std.fmt.parseInt(u16, port_text, 10);

    std.debug.print("port = {}\n", .{port});
}

This turns:

"8080"

into:

8080

of type u16.

Boolean Environment Variables

Suppose:

DEBUG=true

You can parse it manually.

const std = @import("std");

fn parseBool(text: []const u8) !bool {
    if (std.mem.eql(u8, text, "true")) return true;
    if (std.mem.eql(u8, text, "false")) return false;

    return error.InvalidBoolean;
}

pub fn main() !void {
    const allocator = std.heap.page_allocator;

    const debug_text = std.process.getEnvVarOwned(
        allocator,
        "DEBUG",
    ) catch {
        std.debug.print("DEBUG not set\n", .{});
        return;
    };
    defer allocator.free(debug_text);

    const debug = try parseBool(debug_text);

    std.debug.print("debug = {}\n", .{debug});
}

This accepts only:

true
false

That strictness is useful.

Empty Variables

An environment variable can exist but contain an empty string.

Example:

APP_PORT=

The variable exists, but its value is:

an empty string.

This is different from the variable being missing entirely.

So these are separate cases:

SituationMeaning
Variable missingNo value provided
Variable exists but emptyValue provided, but empty

Your program should decide how to handle both.

Listing Environment Variables

Some APIs allow iterating through the whole environment.

The general structure is:

var env_map = try std.process.getEnvMap(allocator);
defer env_map.deinit();

var it = env_map.iterator();

while (it.next()) |entry| {
    std.debug.print("{s} = {s}\n", .{
        entry.key_ptr.*,
        entry.value_ptr.*,
    });
}

This creates a map of environment variables.

Then it iterates through them.

The exact API details can vary across Zig versions, but the idea is stable:

Environment variables are key-value string pairs.

Common Environment Variables

Some variables appear often on Unix-like systems.

VariableMeaning
HOMEUser home directory
PATHExecutable search paths
USERUsername
PWDCurrent working directory
SHELLUser shell
TMPDIRTemporary directory

On Windows, names differ somewhat.

Examples:

VariableMeaning
USERNAMEUsername
TEMPTemporary directory
APPDATAApplication data directory

Portable programs should not assume every variable exists on every platform.

The PATH Variable

PATH is especially important.

It contains directories where the operating system searches for executables.

Example on Linux:

/usr/local/bin:/usr/bin:/bin

The separator is usually:

:

On Windows, it is usually:

;

Programs that work with executable lookup often need platform-aware path splitting.

Security and Environment Variables

Environment variables come from outside the program.

Treat them as untrusted input.

Bad:

const level = try std.fmt.parseInt(u8, value, 10);

without checking the allowed range.

Bad:

const path = value;

without validating whether the path is acceptable.

An attacker may control the environment.

This matters especially for:

paths

temporary directories

shell commands

library loading

configuration values

Environment variables are convenient, but they are still external input.

Avoid Global Hidden State

One reason Zig keeps environment access explicit is that environment variables are global process state.

Hidden global state makes programs harder to reason about.

This style is clearer:

const port = try loadPortFromEnvironment();
runServer(port);

than code that reads environment variables from many unrelated places.

Read configuration once near startup when possible.

Then pass typed values through the program.

A Small Configuration Loader

This example loads a small config from the environment.

const std = @import("std");

const Config = struct {
    port: u16,
    debug: bool,
};

fn parseBool(text: []const u8) !bool {
    if (std.mem.eql(u8, text, "true")) return true;
    if (std.mem.eql(u8, text, "false")) return false;

    return error.InvalidBoolean;
}

fn loadConfig(allocator: std.mem.Allocator) !Config {
    const port_text = std.process.getEnvVarOwned(
        allocator,
        "APP_PORT",
    ) catch return error.MissingPort;
    defer allocator.free(port_text);

    const debug_text = std.process.getEnvVarOwned(
        allocator,
        "DEBUG",
    ) catch return error.MissingDebug;
    defer allocator.free(debug_text);

    return Config{
        .port = try std.fmt.parseInt(u16, port_text, 10),
        .debug = try parseBool(debug_text),
    };
}

pub fn main() !void {
    const allocator = std.heap.page_allocator;

    const config = try loadConfig(allocator);

    std.debug.print("port = {}\n", .{config.port});
    std.debug.print("debug = {}\n", .{config.debug});
}

Run it like this:

APP_PORT=8080 DEBUG=true ./my_program

Output:

port = 8080
debug = true

Default Values

Some programs want defaults.

fn loadPort(allocator: std.mem.Allocator) !u16 {
    const text = std.process.getEnvVarOwned(
        allocator,
        "APP_PORT",
    ) catch return 8080;

    defer allocator.free(text);

    return std.fmt.parseInt(u16, text, 10);
}

This says:

If the variable is missing, use 8080.

But if the variable exists and is invalid:

APP_PORT=hello

parsing still fails.

That is usually the correct behavior.

Missing configuration and malformed configuration are different problems.

Environment Variables Are Strings

This is one of the most important ideas.

Everything starts as text.

The operating system does not know:

this variable is a port number

this variable is a boolean

this variable is JSON

this variable is a path

Your program defines the meaning.

That means your program also defines:

which values are valid

how parsing works

what errors should say

what defaults exist

Common Mistakes

Do not assume environment variables exist.

Do not assume they contain valid values.

Do not silently ignore malformed configuration.

Do not forget to free allocated environment strings.

Do not hard-code platform-specific variable names unless your program is platform-specific.

Do not scatter environment access across the whole program.

The Core Pattern

Read the variable:

const value = try std.process.getEnvVarOwned(
    allocator,
    "NAME",
);
defer allocator.free(value);

Parse the text:

const port = try std.fmt.parseInt(u16, value, 10);

Handle missing variables:

const value = std.process.getEnvVarOwned(
    allocator,
    "NAME",
) catch {
    return default_value;
};

Validate strictly:

if (!std.mem.eql(u8, value, "true")) {
    return error.InvalidValue;
}

What You Should Remember

Environment variables are named text values provided by the operating system.

Programs use them for configuration.

Environment values are strings and must often be parsed.

Missing variables and malformed variables are different cases.

Many environment APIs allocate memory, so free the returned strings.

Treat environment variables as external input.

Load configuration early, validate it clearly, and convert it into typed values before using it throughout the program.