Skip to content

System Calls

A system call is a request from your program to the operating system.

A system call is a request from your program to the operating system.

Your program cannot directly do everything it wants. It cannot directly open files, create processes, read from the network, allocate virtual memory pages, or talk to hardware. Those operations belong to the operating system.

So your program asks.

That request is called a system call.

A normal function call stays inside your program:

const x = add(10, 20);

A system call crosses from your program into the operating system kernel:

const n = try std.posix.read(fd, buffer);

Here, your program asks the operating system to read bytes from a file descriptor.

Why System Calls Exist

Modern operating systems protect programs from each other.

One program should not be able to read another program’s memory. One program should not be able to write to arbitrary disk locations. One program should not be able to control hardware directly without permission.

The operating system kernel sits between programs and the machine.

Your program runs in user space. The kernel runs in kernel space.

User space is where normal application code runs.

Kernel space is where the operating system handles protected work: files, memory, processes, devices, networking, and permissions.

A system call is the controlled doorway between these two worlds.

Common System Calls

You do not need to memorize all system calls now. Start with the common families:

AreaExamplesWhat They Do
Filesopen, read, write, closeWork with files and file descriptors
Memorymmap, munmapMap and unmap virtual memory
Processesfork, exec, waitCreate and manage processes
Timeclock_gettime, nanosleepRead clocks and sleep
Networkingsocket, bind, listen, accept, connectWork with network connections
Metadatastat, fstatRead information about files

In Zig, many of these are exposed through std.posix on POSIX-like systems.

File Descriptors

On POSIX systems such as Linux and macOS, many system calls work with file descriptors.

A file descriptor is a small integer that represents an open resource.

That resource might be:

a file

a directory

a socket

a pipe

a terminal

standard input

standard output

standard error

The usual standard file descriptors are:

File DescriptorNameMeaning
0standard inputWhere input usually comes from
1standard outputWhere normal output usually goes
2standard errorWhere error output usually goes

When you write this:

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

Zig eventually writes bytes to an output stream, and that stream is backed by an operating system resource.

At the low level, output is a request to the operating system.

Calling a Low-Level Write

Here is a small example using a POSIX write call directly:

const std = @import("std");

pub fn main() !void {
    const message = "Hello from a system call\n";

    const stdout = std.posix.STDOUT_FILENO;
    _ = try std.posix.write(stdout, message);
}

This writes bytes to standard output.

The call:

try std.posix.write(stdout, message);

asks the operating system to write message to file descriptor 1.

The return value is the number of bytes written.

Partial Writes

A write call does not always write every byte you give it.

This surprises beginners.

For regular files, writes often complete fully. For pipes, sockets, terminals, and non-blocking file descriptors, a write may write only part of the buffer.

So low-level code must be prepared for partial writes.

A safer helper loops until all bytes are written:

const std = @import("std");

fn writeAll(fd: std.posix.fd_t, bytes: []const u8) !void {
    var remaining = bytes;

    while (remaining.len > 0) {
        const n = try std.posix.write(fd, remaining);
        remaining = remaining[n..];
    }
}

pub fn main() !void {
    try writeAll(std.posix.STDOUT_FILENO, "hello\n");
}

This function keeps writing until the slice is empty.

The important line is:

remaining = remaining[n..];

After writing n bytes, the program removes those bytes from the front of the slice and continues with the rest.

Reading from Standard Input

Reading is the reverse operation.

const std = @import("std");

pub fn main() !void {
    var buffer: [1024]u8 = undefined;

    const stdin = std.posix.STDIN_FILENO;
    const n = try std.posix.read(stdin, &buffer);

    const bytes = buffer[0..n];
    try writeAll(std.posix.STDOUT_FILENO, bytes);
}

fn writeAll(fd: std.posix.fd_t, bytes: []const u8) !void {
    var remaining = bytes;

    while (remaining.len > 0) {
        const n = try std.posix.write(fd, remaining);
        remaining = remaining[n..];
    }
}

This program reads up to 1024 bytes from standard input, then writes those bytes back to standard output.

If you run it and type text, it echoes the text back.

End of File

A read call returns 0 when it reaches end of file.

For a regular file, this means there are no more bytes.

For standard input, it may mean the input stream has been closed.

A common read loop looks like this:

const std = @import("std");

pub fn main() !void {
    var buffer: [4096]u8 = undefined;

    while (true) {
        const n = try std.posix.read(std.posix.STDIN_FILENO, &buffer);

        if (n == 0) {
            break;
        }

        try writeAll(std.posix.STDOUT_FILENO, buffer[0..n]);
    }
}

fn writeAll(fd: std.posix.fd_t, bytes: []const u8) !void {
    var remaining = bytes;

    while (remaining.len > 0) {
        const n = try std.posix.write(fd, remaining);
        remaining = remaining[n..];
    }
}

This is the basic shape of many Unix-style programs:

read bytes

process bytes

write bytes

repeat until end of file

System Calls Can Fail

System calls interact with the outside world, so they fail often.

A file may not exist.

A permission check may fail.

A disk may be full.

A network connection may close.

A signal may interrupt a call.

A file descriptor may be invalid.

In Zig, these failures become errors. That is why many low-level calls return error unions.

const n = try std.posix.read(fd, buffer);

The try means: if the read fails, return the error from the current function.

You can also handle the error directly:

const n = std.posix.read(fd, buffer) catch |err| {
    std.debug.print("read failed: {}\n", .{err});
    return err;
};

For systems programming, this explicit style is useful. It keeps failure visible.

System Calls Are Expensive Compared with Normal Function Calls

A normal function call is cheap.

A system call is more expensive because the CPU must cross from user space into kernel space. The operating system must check permissions, inspect arguments, perform the operation, and return control to your program.

This does not mean system calls are bad. They are necessary.

But it does mean you should avoid unnecessary system calls in hot loops.

For example, this is inefficient:

for (bytes) |b| {
    _ = try std.posix.write(std.posix.STDOUT_FILENO, bytes[index..index + 1]);
}

Writing one byte at a time causes many system calls.

This is better:

_ = try std.posix.write(std.posix.STDOUT_FILENO, bytes);

One larger write is usually better than many tiny writes.

Use std First, Use std.posix When Needed

Most Zig programs should start with higher-level standard library APIs.

For example, instead of calling POSIX open directly, you can use:

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

Instead of manually calling low-level write, you can use writers:

const stdout = std.io.getStdOut().writer();
try stdout.print("hello {s}\n", .{"zig"});

These APIs are easier to read and more portable.

Use std.posix when you need lower-level control or when you are learning how the operating system works.

Portability

System calls are operating-system specific.

Linux, macOS, Windows, and other systems expose different kernel interfaces. POSIX gives Unix-like systems a shared style, but details still vary.

Zig helps by providing standard library abstractions where possible.

For portable code, prefer:

std.fs
std.io
std.process
std.net
std.Thread

For OS-specific code, use lower-level modules such as:

std.posix
std.os.linux
std.os.windows

The deeper you go, the more you need to know about the target operating system.

Mental Model

A system call is not just another library function.

It is a boundary crossing.

Your program asks the operating system to do protected work. The operating system checks the request, performs the operation if allowed, and returns either a result or an error.

For Zig programmers, this model fits naturally. Zig already makes errors explicit, memory explicit, and control flow explicit. System calls follow the same principle: the important parts should be visible in the code.