Implementing ls in Zig 0.16.x

In Zig 0.16.0, directory handling has moved entirely into the std.Io namespace, with a new type std.Io.Dir that replaces the old std.fs.Dir. The way you open a directory now comes in two distinct flavours, both available as static methods on std.Io.Dir.

The first is std.Io.Dir.cwd().openDir(io, path, flags). This gives you a std.Io.Dir handle to a subdirectory of the process’s current working directory. The cwd() method returns a special directory object representing the working directory itself; calling openDir on it resolves relative paths (like ".", "..", or "src") relative to that working directory. If you pass an absolute path (e.g., "/tmp"), the working directory is ignored and the absolute path is used directly. This matches the behaviour of traditional command‑line tools and is ideal for user‑supplied paths.

The second is std.Io.Dir.openDirAbsolute(io, absolute_path, flags). This method requires an absolute path (on UNIX‑like systems it must start with /) and completely ignores the current working directory. The path is validated at compile time to be absolute, so mistakes like forgetting the leading slash are caught early. This function is perfect when your program needs to reliably access fixed system locations such as /etc, /var/log, /proc, or the root directory, regardless of how or from where the program was launched.

Notice that every operation that touches the file system now receives an explicit io handle — the io instance provided by the “Juicy Main” pattern (std.process.Init). This explicit passing makes the I/O dependency clear and aids in testability and portability.

Here is a complete working program that lists the contents of the current directory and then the root directory, using the correct Zig 0.16.0 API:

const std = @import("std");

pub fn main(init: std.process.Init) !void {
    const io = init.io;
    var dir: std.Io.Dir = try std.Io.Dir.cwd().openDir(
        io,
        ".",
        .{ .iterate = true },
    );
    defer dir.close(io);

    var dirIterator = dir.iterate();
    while (try dirIterator.next(io)) |dirContent| {
        std.debug.print("{s}\n", .{dirContent.name});
    }

    std.debug.print("\n", .{});
    dir = try std.Io.Dir.openDirAbsolute(io, "/", .{ .iterate = true });

    dirIterator = dir.iterate();
    while (try dirIterator.next(io)) |dirContent| {
        std.debug.print("{s}\n", .{dirContent.name});
    }
}

Once you have an open std.Io.Dir handle, the idiomatic way to read its contents is through the dir.iterate() method. This method returns a lightweight iterator (std.Io.Dir.Iterator) that lazily reads one directory entry at a time directly from the operating system — no upfront allocation, no hidden vector of strings, and no extra memory usage beyond a small internal OS buffer. Each call to iterator.next(io) performs the actual system call, passing the same io handle that was used to open the directory. It returns an error union !?Entry, meaning it can fail with an I/O error (which you must handle with try) and returns null when there are no more entries.

The returned Entry struct gives you just what you need: a .name field containing the filename as a slice, and a .kind field that tells you whether the entry is a regular file, directory, symbolic link, block device, etc. Because the iterator is completely lazy and holds no resources beyond the already‑open directory handle, you can safely create many of them at once — perfect for recursive traversal — and you can exit the loop early without leaking anything, since defer dir.close(io) takes care of cleanup even on errors.

You must open the directory with the .iterate = true flag; if you forget, Zig catches it at compile time and refuses to let you call .iterate(), preventing subtle runtime bugs. In short, the combination of std.Io.Dir, explicit io passing, and lazy iteration gives you safe, efficient, allocation‑free directory walking while keeping errors explicit and resource management automatic — exactly what makes Zig’s standard library feel both modern and trustworthy.

Save the program as ls.zig and execute it with Zig 0.16.0:

$ zig version
0.16.0
$ zig run ls.zig
.DS_Store
code
TODO.md
README.md
methods.md
.gitignore
.git
ts

home
usr
bin
sbin
etc
var
opt
dev
tmp

Happy coding in Zig!