File Seeking in Zig

One of the most notable changes in Zig 0.16 is the overhaul of the file I/O API. The traditional pattern of calling seek() followed by read() has been largely replaced with a cleaner and more explicit approach using positional reads.

The following program demonstrates how to read data from a specific byte offset in a file without modifying the file current position:

const std = @import("std");

pub fn main(init: std.process.Init) !void {
    const args = try init.minimal.args.toSlice(
        init.arena.allocator(),
    );

    if (args.len < 2) {
        std.debug.print("Usage: {s} <file>\n", .{args[0]});
        return;
    }
    const filePath = args[1];
    const file = try std.Io.Dir.cwd().openFile(init.io, filePath, .{});
    defer file.close(init.io);

    // In Zig 0.16, file I/O uses positional reads instead of seek+read.
    // Read 1 byte at offset 5 directly, without changing any seek position.
    var byte_buf: [1]u8 = undefined;
    const n = try file.readPositionalAll(init.io, &byte_buf, 5);
    if (n > 0) {
        const byte = byte_buf[0];
        std.debug.print("Byte at offset 5: 0x{x}\n", .{byte_buf[0]});

        // Print the actual character if it is printable
        if (std.ascii.isPrint(byte)) {
            std.debug.print("Character: '{c}'\n", .{byte});
        } else {
            std.debug.print("Character: (non-printable)\n", .{});
        }
    } else {
        std.debug.print("File is shorter than 5 bytes.\n", .{});
    }

    // Get file size via stat
    const stat = try file.stat(init.io);
    std.debug.print("File size: {d} bytes\n", .{stat.size});
}

In earlier versions of Zig, reading from a specific offset typically required two steps: first seeking to the desired position with seekTo(), then performing a read(). This approach mutated the file’s internal cursor and could lead to subtle bugs, especially in more complex code.

Zig 0.16 introduces positional I/O functions like readPositionalAll(), which allow you to read data from any byte offset in a single operation — without affecting the file’s current seek position.

This change makes the code more predictable, easier to reason about, and better suited for both synchronous and asynchronous programming.

  • std.process.Init provides access to command-line arguments and the I/O context in the new Zig 0.16 style.
  • std.Io.Dir.cwd().openFile() opens the file using the updated I/O interface.
  • file.readPositionalAll(init.io, &byte_buf, 5) reads bytes starting at offset 5. The All suffix means it tries to completely fill the provided buffer.
  • file.stat(init.io) retrieves metadata about the open file, including its total size in bytes.
  • After reading the byte, the program checks if it is printable using std.ascii.isPrint() and displays the character with {c} if it is.

This new API is part of Zig’s ongoing effort to make I/O operations explicit, consistent, and robust across different contexts.

As Zig moves closer to version 1.0, expect even more emphasis on positional and vectored I/O patterns rather than relying on implicit file cursors.

Save the code as lseek.zig and run it on a sample file:

$ zig version
0.16.0
$ cat /tmp/test.txt
123456789
$ zig run /tmp/lseek.zig -- /tmp/test.txt
Byte at offset 5: 0x36
Character: '6'
File size: 10 bytes

Happy coding in Zig!