Systems programming means working directly with the operating system.
The operating system gives your program access to files, processes, memory, clocks, terminals, environment variables, network sockets, permissions, and hardware-backed resources. Zig can use all of these, but it usually gives you two levels of access.
The first level is portable standard library code. This is what you should use first.
std.fs
std.io
std.process
std.Thread
std.time
std.netThe second level is operating-system-specific code. This is what you use when you need exact control.
std.posix
std.os.linux
std.os.windowsGood Zig code usually starts portable and only becomes OS-specific where it has to.
The Operating System Is Not One Thing
When beginners hear “the OS,” they often think of the desktop: windows, icons, menus, and apps.
For systems programming, the operating system mostly means the services your program can request.
Your program asks the OS to open a file.
Your program asks the OS to start another process.
Your program asks the OS what time it is.
Your program asks the OS to allocate virtual memory.
Your program asks the OS to listen on a network port.
Your program asks the OS which environment variables are set.
The operating system is the layer that owns these resources and controls access to them.
Paths and the Current Working Directory
Most programs need to work with paths.
A path is a name that points to a file or directory.
notes.txt
src/main.zig
/tmp/output.log
C:\Users\name\Desktop\data.txtSome paths are relative. A relative path is interpreted from the current working directory.
data.txt
src/main.zigSome paths are absolute. An absolute path starts from the root of the filesystem.
On Unix-like systems:
/home/alice/project/data.txtOn Windows:
C:\Users\Alice\project\data.txtIn Zig, std.fs.cwd() gives you a handle to the current working directory.
const std = @import("std");
pub fn main() !void {
var file = try std.fs.cwd().openFile("data.txt", .{});
defer file.close();
std.debug.print("opened data.txt\n", .{});
}This opens data.txt relative to the directory where the program is running.
That may not be the same directory where the executable lives. This matters. Programs are often launched from different directories.
Creating a File
To create or replace a file, use createFile.
const std = @import("std");
pub fn main() !void {
var file = try std.fs.cwd().createFile("output.txt", .{});
defer file.close();
try file.writeAll("hello from zig\n");
}This creates output.txt in the current working directory.
The important call is:
try file.writeAll("hello from zig\n");Unlike a raw low-level write, writeAll keeps writing until all bytes are written or an error happens.
For normal application code, prefer writeAll over manual partial-write handling.
Reading a Whole File
For small files, reading the whole file is simple.
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const bytes = try std.fs.cwd().readFileAlloc(
allocator,
"data.txt",
1024 * 1024,
);
defer allocator.free(bytes);
std.debug.print("{s}\n", .{bytes});
}The last argument is the maximum file size allowed.
1024 * 1024This protects your program from accidentally reading a huge file into memory.
The file contents are allocated on the heap, so you must free them.
defer allocator.free(bytes);This is a common Zig pattern: allocation is explicit, and cleanup is explicit.
Environment Variables
Environment variables are key-value strings passed to a process by its parent process or shell.
Examples:
HOME=/home/alice
PATH=/usr/bin:/bin
EDITOR=vimPrograms use environment variables for configuration.
In Zig, you can read an environment variable with std.process.getEnvVarOwned.
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const home = 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(home);
std.debug.print("HOME = {s}\n", .{home});
}The returned string is allocated, so it must be freed.
On Windows, the equivalent variable may not be HOME. Portable programs should be careful with environment assumptions.
Command-Line Arguments
Command-line arguments are the words passed after the program name.
For example:
mytool input.txt output.txtThe arguments are:
input.txt
output.txtIn Zig, you can get them with std.process.argsAlloc.
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
for (args, 0..) |arg, i| {
std.debug.print("arg {} = {s}\n", .{ i, arg });
}
}The first argument is usually the program name or path.
This means a command like:
./mytool input.txtoften produces:
arg 0 = ./mytool
arg 1 = input.txtDo not assume there is always an argument at index 1. Check first.
if (args.len < 2) {
std.debug.print("usage: mytool <file>\n", .{});
return;
}Exit Codes
A program returns an exit code to the operating system.
By convention:
0 means success
non-zero means failureIn Zig, returning an error from main usually reports failure. For explicit control, you can use std.process.exit.
const std = @import("std");
pub fn main() void {
std.debug.print("fatal: missing input\n", .{});
std.process.exit(1);
}Use exit codes carefully in command-line tools. Other programs and scripts may depend on them.
Running Another Program
Programs can start other programs.
In Zig, std.process.Child gives you control over child processes.
const std = @import("std");
pub fn main() !void {
var child = std.process.Child.init(&[_][]const u8{
"zig",
"version",
}, std.heap.page_allocator);
const result = try child.spawnAndWait();
std.debug.print("child exited with: {}\n", .{result});
}This starts:
zig versionThe child process runs separately. Your program waits for it to finish.
For real tools, you often need to handle stdout, stderr, stdin, environment variables, and working directory. But the basic model is simple: create a child process, configure it, spawn it, wait for it.
Temporary Directories
Many programs need temporary files.
Temporary files are useful for intermediate output, tests, downloads, caches, and atomic file replacement.
The standard library has APIs for temporary directories and testing support. The exact choice depends on the situation.
A simple pattern is to create temporary work inside a known directory and clean it up with defer.
const std = @import("std");
pub fn main() !void {
try std.fs.cwd().makeDir("tmp-work");
defer std.fs.cwd().deleteTree("tmp-work") catch {};
var file = try std.fs.cwd().createFile("tmp-work/output.txt", .{});
defer file.close();
try file.writeAll("temporary data\n");
}The cleanup line uses catch {} because cleanup can fail, and this example chooses to ignore cleanup errors.
defer std.fs.cwd().deleteTree("tmp-work") catch {};In production code, you may want to log cleanup errors instead of ignoring them.
Permissions
Operating systems use permissions to decide what programs may do.
A file may be readable but not writable.
A directory may allow listing but not creation.
A port may require special privileges.
A process may be unable to signal another process.
In Zig, permission failures appear as errors.
var file = std.fs.cwd().openFile("/root/secret.txt", .{}) catch |err| {
std.debug.print("cannot open file: {}\n", .{err});
return;
};
defer file.close();Do not treat OS operations as guaranteed. Almost every OS interaction can fail.
OS Differences
A program that works on Linux may not work the same way on Windows.
Common differences include:
| Topic | Unix-like Systems | Windows |
|---|---|---|
| Path separator | / | \ and often / accepted by APIs |
| Root path | / | Drive roots like C:\ |
| Executable files | Permission bit matters | File extension often matters |
| Environment names | Often case-sensitive | Usually case-insensitive |
| Process model | POSIX-style APIs | Windows-specific APIs |
| Newlines | \n convention | Text tools may use \r\n |
Zig helps, but it cannot erase every OS difference.
For portable code, avoid hardcoding path separators. Prefer standard library path and filesystem APIs.
Prefer Handles Over Global Paths
A good systems-programming habit is to work relative to directory handles instead of constantly building global path strings.
This:
var dir = try std.fs.cwd().openDir("data", .{});
defer dir.close();
var file = try dir.openFile("input.txt", .{});
defer file.close();is often better than manually constructing:
data/input.txtDirectory handles can make code clearer, safer, and easier to adapt.
They also match how operating systems often work internally: open a resource, receive a handle, operate through that handle, close it when done.
Resource Lifetime
When you work with the OS, you work with resources.
Files must be closed.
Directories must be closed.
Allocated argument arrays must be freed.
Child processes must be waited for or otherwise managed.
Mapped memory must be unmapped.
Sockets must be closed.
The Zig pattern is:
const resource = try acquire();
defer release(resource);Example:
var file = try std.fs.cwd().openFile("data.txt", .{});
defer file.close();This is one of the most important patterns in Zig systems programming.
Acquire the resource. Immediately write the cleanup. Then use the resource.
Mental Model
Working with the OS means asking for resources, using handles, checking errors, and releasing everything you acquire.
Zig makes this explicit.
You can write high-level code with std.fs, std.process, std.io, and std.net. When needed, you can go lower with std.posix or OS-specific modules.
Start with the portable API. Drop down only when the portable API does not expose the control you need.