Skip to content

Portable APIs

A portable API is an interface that works across more than one platform.

A portable API is an interface that works across more than one platform.

In Zig, this usually means code that can run on Windows, Linux, macOS, and sometimes other targets without rewriting the program for each operating system.

The main idea is simple: write against Zig’s standard library when possible, and isolate platform-specific code when necessary.

Why Portable APIs Matter

Operating systems differ.

Windows paths look like this:

C:\Users\alice\data.txt

Linux and macOS paths look like this:

/home/alice/data.txt
/Users/alice/data.txt

Windows uses handles for many operating system objects. Unix-like systems use file descriptors. Newline conventions, dynamic libraries, permissions, process behavior, and system APIs all differ.

A portable API hides some of those differences behind one Zig interface.

For example, instead of writing separate Windows and Linux file-opening code, you can use:

const file = try std.fs.cwd().openFile("data.txt", .{});
defer file.close();

This code can work on several platforms.

Start with std

The standard library is your first portability layer.

Use it for common tasks:

files
directories
paths
process arguments
environment variables
stdin
stdout
stderr
time
networking
formatting
memory allocation

For example, this code reads a file using std.fs:

const std = @import("std");

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

    const data = try std.fs.cwd().readFileAlloc(
        allocator,
        "input.txt",
        1024 * 1024,
    );
    defer allocator.free(data);

    try std.io.getStdOut().writer().print("{s}", .{data});
}

It avoids direct Windows APIs, POSIX APIs, or platform-specific file descriptors.

Keep Platform-Specific Code Small

Sometimes portable APIs are not enough.

You may need Windows-specific behavior, Linux-specific behavior, or macOS-specific behavior.

In that case, keep the platform-specific part small and place it behind your own function.

Bad shape:

// Platform checks scattered everywhere.
if (builtin.os.tag == .windows) {
    // Windows path here
} else {
    // Unix path here
}

// More code...

if (builtin.os.tag == .windows) {
    // Another Windows branch
} else {
    // Another Unix branch
}

Better shape:

fn platformName() []const u8 {
    const builtin = @import("builtin");

    return switch (builtin.os.tag) {
        .windows => "windows",
        .linux => "linux",
        .macos => "macos",
        else => "other",
    };
}

Then the rest of the program can call:

std.debug.print("platform: {s}\n", .{platformName()});

The platform decision is centralized.

Compile-Time Platform Checks

Zig exposes target information through @import("builtin").

const builtin = @import("builtin");

You can inspect the target OS:

if (builtin.os.tag == .windows) {
    // Windows code
}

You can use switch:

const std = @import("std");
const builtin = @import("builtin");

fn lineEnding() []const u8 {
    return switch (builtin.os.tag) {
        .windows => "\r\n",
        else => "\n",
    };
}

pub fn main() void {
    std.debug.print("hello{s}", .{lineEnding()});
}

This decision happens at compile time. If you build for Windows, the Windows branch is selected. If you build for Linux or macOS, the other branch is selected.

Do Not Guess the Platform from Strings

Avoid code like this:

const os_name = "windows";

or:

if (std.mem.eql(u8, os_name, "windows")) {
    // ...
}

Zig already knows the target. Use builtin.os.tag.

This is clearer and safer.

Portable Path Handling

Do not manually build paths by concatenating strings with / or \.

Bad:

const path = "data/" ++ "users/" ++ "alice.txt";

This assumes Unix-style separators.

Prefer path helpers:

const std = @import("std");

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

    const path = try std.fs.path.join(
        allocator,
        &.{ "data", "users", "alice.txt" },
    );
    defer allocator.free(path);

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

This lets the standard library choose the right separator for the target.

Portable File Access

Use std.fs for ordinary file work.

Open a file:

const file = try std.fs.cwd().openFile("config.txt", .{});
defer file.close();

Create or overwrite a file:

const file = try std.fs.cwd().createFile("output.txt", .{});
defer file.close();

try file.writeAll("hello\n");

Create a directory:

try std.fs.cwd().makeDir("data");

Handle the error if it already exists:

std.fs.cwd().makeDir("data") catch |err| switch (err) {
    error.PathAlreadyExists => {},
    else => return err,
};

This style works better than calling raw OS APIs directly.

Portable Command-Line Arguments

Use std.process to read command-line arguments:

const std = @import("std");

pub fn main() !void {
    var args = try std.process.argsWithAllocator(std.heap.page_allocator);
    defer args.deinit();

    while (args.next()) |arg| {
        std.debug.print("arg: {s}\n", .{arg});
    }
}

This avoids dealing directly with Windows command-line parsing or Unix argv.

The shell still matters. PowerShell, Command Prompt, Bash, and Zsh parse commands differently before your program receives arguments. But your Zig code should use the standard argument API.

Portable Environment Variables

Use std.process for environment variables:

const std = @import("std");

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

    const value = std.process.getEnvVarOwned(allocator, "HOME") catch |err| switch (err) {
        error.EnvironmentVariableNotFound => {
            std.debug.print("HOME is not set\n", .{});
            return;
        },
        else => return err,
    };
    defer allocator.free(value);

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

This code is portable in structure, but variable names are not always portable.

For example, Unix-like systems often use:

HOME

Windows often uses:

USERPROFILE

So the API can be portable while the environment convention still differs.

A better abstraction is to write your own function:

const std = @import("std");
const builtin = @import("builtin");

fn homeDir(allocator: std.mem.Allocator) ![]u8 {
    const name = switch (builtin.os.tag) {
        .windows => "USERPROFILE",
        else => "HOME",
    };

    return std.process.getEnvVarOwned(allocator, name);
}

Now the rest of your program calls homeDir.

Portable Output

For command-line tools, use stdout for normal output:

try std.io.getStdOut().writer().print("result\n", .{});

Use stderr for diagnostics:

try std.io.getStdErr().writer().print("error: missing input\n", .{});

This convention works across major platforms and matters for scripts, pipes, redirects, CI logs, and shell users.

Avoid Platform-Specific Assumptions

Portable code should avoid assumptions such as:

every system uses /
every executable has .exe
every system has fork
every terminal supports the same escape codes
every filesystem is case-sensitive
every newline is \n
every path is valid UTF-8
every system has the same environment variables

Some assumptions work often enough to fool you during development. Then they fail on another platform.

Portable code is mostly careful code.

Designing Your Own Portable Layer

For larger programs, create a small internal platform layer.

Example layout:

src/
  main.zig
  platform.zig

platform.zig can contain target-specific decisions:

const std = @import("std");
const builtin = @import("builtin");

pub fn defaultConfigDir(allocator: std.mem.Allocator) ![]u8 {
    return switch (builtin.os.tag) {
        .windows => try windowsConfigDir(allocator),
        .linux => try linuxConfigDir(allocator),
        .macos => try macosConfigDir(allocator),
        else => error.UnsupportedPlatform,
    };
}

fn windowsConfigDir(allocator: std.mem.Allocator) ![]u8 {
    const base = try std.process.getEnvVarOwned(allocator, "APPDATA");
    return base;
}

fn linuxConfigDir(allocator: std.mem.Allocator) ![]u8 {
    const base = try std.process.getEnvVarOwned(allocator, "XDG_CONFIG_HOME");
    return base;
}

fn macosConfigDir(allocator: std.mem.Allocator) ![]u8 {
    const home = try std.process.getEnvVarOwned(allocator, "HOME");
    defer allocator.free(home);

    return try std.fs.path.join(allocator, &.{ home, "Library", "Application Support" });
}

Then main.zig uses the abstraction:

const std = @import("std");
const platform = @import("platform.zig");

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

    const dir = try platform.defaultConfigDir(allocator);
    defer allocator.free(dir);

    try std.io.getStdOut().writer().print("{s}\n", .{dir});
}

The platform details are kept in one file.

Use Errors for Unsupported Targets

Sometimes a program cannot support every target.

That is acceptable. Say so clearly in code.

const builtin = @import("builtin");

fn requireUnixLike() !void {
    switch (builtin.os.tag) {
        .linux, .macos => {},
        else => return error.UnsupportedPlatform,
    }
}

Or fail at compile time:

const builtin = @import("builtin");

comptime {
    switch (builtin.os.tag) {
        .linux, .macos => {},
        else => @compileError("this program only supports Linux and macOS"),
    }
}

Runtime error is better when the program can still build but a feature is unavailable.

Compile error is better when the program fundamentally cannot build for that target.

Test on Real Targets

A program that compiles for a target may still behave incorrectly on that target.

Cross-compilation catches many build problems, but not all runtime problems.

For portable software, test at least the major targets you claim to support:

Windows
Linux
macOS

For CPU-specific software, also test architectures:

x86_64
aarch64

For Wasm, test the actual host:

browser
Wasmtime
Node.js
custom runtime

A target triple is a build choice. Real support requires runtime testing.

Complete Example

This program prints a platform name and joins a path portably:

const std = @import("std");
const builtin = @import("builtin");

fn platformName() []const u8 {
    return switch (builtin.os.tag) {
        .windows => "windows",
        .linux => "linux",
        .macos => "macos",
        else => "other",
    };
}

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

    const path = try std.fs.path.join(
        allocator,
        &.{ "data", "users", "alice.txt" },
    );
    defer allocator.free(path);

    try stdout.print("platform: {s}\n", .{platformName()});
    try stdout.print("path: {s}\n", .{path});
}

Build for the current system:

zig build-exe main.zig

Build for Windows:

zig build-exe main.zig -target x86_64-windows

Build for Linux:

zig build-exe main.zig -target x86_64-linux

Build for macOS:

zig build-exe main.zig -target aarch64-macos

The same source code can target different systems because it uses portable APIs and keeps the platform check small.

The Practical View

Portable Zig code starts with the standard library. Use std.fs, std.process, std.io, allocators, and compile-time target checks before reaching for raw OS APIs.

When platform-specific behavior is necessary, isolate it. Put it behind your own small functions. Keep the rest of the program clean.

A good portable API does not pretend all platforms are the same. It gives your program one clear interface while keeping each platform’s differences contained.