Skip to content

Handling Platform Differences

A cross-platform Zig program should not pretend that every operating system behaves the same way. Windows, Linux, macOS, WebAssembly, and embedded targets have different...

A cross-platform Zig program should not pretend that every operating system behaves the same way. Windows, Linux, macOS, WebAssembly, and embedded targets have different rules. Good portable code accepts those differences, puts them in clear places, and keeps the rest of the program simple.

The goal is not to remove platform differences. The goal is to control where they appear.

The Problem

Suppose your program needs a configuration directory.

On Linux, a common location may come from:

XDG_CONFIG_HOME

On macOS, application support files often live under:

~/Library/Application Support

On Windows, application data often comes from:

APPDATA

A bad program spreads these checks everywhere:

if (builtin.os.tag == .windows) {
    // Windows logic
} else if (builtin.os.tag == .linux) {
    // Linux logic
} else if (builtin.os.tag == .macos) {
    // macOS logic
}

That becomes hard to maintain. Every feature starts carrying platform branches.

A better program hides the difference behind one function:

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

fn configDir(allocator: std.mem.Allocator) ![]u8 {
    return switch (builtin.os.tag) {
        .windows => try std.process.getEnvVarOwned(allocator, "APPDATA"),
        .linux => try std.process.getEnvVarOwned(allocator, "XDG_CONFIG_HOME"),
        .macos => blk: {
            const home = try std.process.getEnvVarOwned(allocator, "HOME");
            defer allocator.free(home);

            break :blk try std.fs.path.join(
                allocator,
                &.{ home, "Library", "Application Support" },
            );
        },
        else => error.UnsupportedPlatform,
    };
}

Now the rest of the program calls configDir. It does not need to know how each platform stores configuration.

Use builtin for Target Checks

Zig exposes compile-time target information through:

const builtin = @import("builtin");

You can check the operating system:

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

You can also use a switch:

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

These checks are based on the target you compile for, not necessarily the machine you are currently using.

For example:

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

When building with this target, builtin.os.tag is .windows, even if you run the compiler on Linux or macOS.

Separate Portable Code from Platform Code

A good structure is:

src/
  main.zig
  platform.zig
  platform_windows.zig
  platform_unix.zig

main.zig should contain the main program logic.

platform.zig should expose a small portable interface.

Platform-specific files should contain the details.

For example, platform.zig:

const builtin = @import("builtin");

pub const impl = switch (builtin.os.tag) {
    .windows => @import("platform_windows.zig"),
    .linux, .macos => @import("platform_unix.zig"),
    else => @compileError("unsupported platform"),
};

pub const configDir = impl.configDir;

Then main.zig can use:

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

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

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

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

This keeps the main program clean.

Prefer Compile-Time Branches for Platform Code

Platform differences are often known at compile time. That means Zig can compile only the relevant branch for the target.

Example:

const builtin = @import("builtin");

fn pathSeparator() u8 {
    return switch (builtin.os.tag) {
        .windows => '\\',
        else => '/',
    };
}

When targeting Windows, the result is \\. When targeting Linux or macOS, the result is /.

But do not write this function unless you really need it. For paths, std.fs.path is usually better.

Avoid Manual Platform Logic When std Already Handles It

Bad:

fn joinPath(a: []const u8, b: []const u8) []const u8 {
    // Incorrect: ignores allocation and platform rules.
    return a ++ "/" ++ b;
}

Better:

const std = @import("std");

fn makePath(allocator: std.mem.Allocator) ![]u8 {
    return try std.fs.path.join(
        allocator,
        &.{ "data", "users", "alice.txt" },
    );
}

Use the standard library for common tasks. Write platform branches only when the standard library does not express the behavior you need.

Handle Unsupported Platforms Explicitly

Do not let unsupported targets fail in strange ways.

Use a runtime error when the program can build but a feature is unavailable:

fn openSystemSettings() !void {
    return switch (builtin.os.tag) {
        .windows => openWindowsSettings(),
        .linux => openLinuxSettings(),
        .macos => openMacSettings(),
        else => error.UnsupportedPlatform,
    };
}

Use a compile error when the program cannot make sense on that target:

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

A compile error is better for impossible targets. A runtime error is better for optional features.

Watch for Hidden Platform Differences

Some differences are obvious. Others are easy to miss.

File paths differ. Windows accepts drive letters and backslashes. Unix-like systems use /.

Executable names differ. Windows commonly uses .exe; Linux and macOS usually do not.

Newlines may differ. Windows text files often use \r\n; Unix-like systems use \n.

Filesystem case sensitivity differs. Linux is usually case-sensitive. Windows is usually case-insensitive. macOS depends on the filesystem configuration.

Environment variables differ. Unix-like systems often use HOME. Windows often uses USERPROFILE, APPDATA, or LOCALAPPDATA.

Dynamic library extensions differ. Windows uses .dll, Linux uses .so, and macOS uses .dylib.

Terminal behavior differs. ANSI escape support, Unicode display, and shell parsing can vary.

Process APIs differ. Unix has fork. Windows does not use the same process model.

Security rules differ. macOS has signing and Gatekeeper. Windows has SmartScreen and Defender. Linux varies by distribution and sandbox.

Good cross-platform code assumes these differences exist.

Design Around Capabilities, Not Names

Sometimes the best question is not “Which OS am I on?” but “Is this feature available?”

For example, instead of scattering this:

if (builtin.os.tag == .linux) {
    // use feature
}

create a function:

fn supportsFeatureX() bool {
    return switch (builtin.os.tag) {
        .linux => true,
        else => false,
    };
}

Then the calling code reads better:

if (supportsFeatureX()) {
    try useFeatureX();
} else {
    try useFallback();
}

This makes the code communicate intent.

The operating system is an implementation detail. The capability is usually what the program cares about.

Keep Data Formats Portable

Platform differences are not only about system calls. They also affect files your program creates.

Avoid writing data in a platform-dependent way unless that is intentional.

Prefer explicit formats:

UTF-8 text
JSON
TOML
binary formats with fixed endianness
newline rules chosen by the format

For binary formats, choose byte order deliberately.

Example:

const std = @import("std");

fn writeU32Little(writer: anytype, value: u32) !void {
    var buf: [4]u8 = undefined;
    std.mem.writeInt(u32, &buf, value, .little);
    try writer.writeAll(&buf);
}

This code writes a 32-bit integer in little-endian order regardless of the host CPU.

That matters if a file written on one system must be read on another.

Test Platform Code Directly

If you isolate platform code, it becomes easier to test.

Example:

test "platform name is not empty" {
    try std.testing.expect(platformName().len > 0);
}

For real platform behavior, use CI across targets:

Windows
Linux
macOS

Cross-compilation can prove that your code builds for a target. It does not prove that the program behaves correctly on that target.

A Windows binary should be tested on Windows. A macOS binary should be tested on macOS. A Linux binary should be tested on Linux.

Complete Example

Here is a small platform layer:

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

pub fn appDataDir(allocator: std.mem.Allocator) ![]u8 {
    return switch (builtin.os.tag) {
        .windows => try std.process.getEnvVarOwned(allocator, "APPDATA"),

        .linux => std.process.getEnvVarOwned(allocator, "XDG_DATA_HOME") catch |err| switch (err) {
            error.EnvironmentVariableNotFound => blk: {
                const home = try std.process.getEnvVarOwned(allocator, "HOME");
                defer allocator.free(home);

                break :blk try std.fs.path.join(
                    allocator,
                    &.{ home, ".local", "share" },
                );
            },
            else => return err,
        },

        .macos => blk: {
            const home = try std.process.getEnvVarOwned(allocator, "HOME");
            defer allocator.free(home);

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

        else => error.UnsupportedPlatform,
    };
}

pub fn executableExtension() []const u8 {
    return switch (builtin.os.tag) {
        .windows => ".exe",
        else => "",
    };
}

And here is a program using it:

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

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

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

    try stdout.print("app data dir: {s}\n", .{dir});
    try stdout.print("executable extension: {s}\n", .{platform.executableExtension()});
}

The main program does not contain OS-specific branches. It asks the platform layer for the information it needs.

The Practical View

Handling platform differences well is mostly about code organization.

Use std for portable behavior. Use builtin.os.tag for target-specific decisions. Keep platform branches out of your main logic. Put platform details behind small functions with clear names. Fail explicitly when a target or feature is unsupported.

A cross-platform Zig program should have a clean center and narrow platform edges.