Ls in Zig
In Zig, the two main ways to open a directory — std.fs.cwd().openDir() and std.fs.openDirAbsolute() — differ primarily in how they interpret the path you give them.
std.fs.cwd().openDir(path, flags) always resolves the path relative to the process’s current working directory at runtime. This is the same behavior you’re used to from tools like ls or cat: if you can pass ".", "..", "src", or even an absolute path like "/tmp", and Zig will treat it exactly like the operating system does when opening files — relative paths are appended to whatever directory the program is currently “in.” This makes it perfect for everyday command-line tools and scripts where the user-provided paths are naturally relative to where the program was started or to where it has navigated with chdir.
On the other hand, std.fs.openDirAbsolute(absolute_path, flags) 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 ideal 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, and even if the process changes its working directory during execution.
In short, use cwd().openDir() for flexible, user-oriented path handling, and use openDirAbsolute() when you need predictable, unambiguous access to a specific place in the filesystem.
The following program illustrates both functions:
const std = @import("std");
pub fn main() !void {
var dir: std.fs.Dir = try std.fs.cwd().openDir(".", .{ .iterate = true });
defer dir.close();
var dirIterator = dir.iterate();
while (try dirIterator.next()) |dirContent| {
std.debug.print("{s} ", .{dirContent.name});
}
std.debug.print("\n", .{});
dir = try std.fs.openDirAbsolute("/", .{ .iterate = true });
dirIterator = dir.iterate();
while (try dirIterator.next()) |dirContent| {
std.debug.print("{s} ", .{dirContent.name});
}
std.debug.print("\n", .{});
}
Once you have an open std.fs.Dir handle, the idiomatic and only supported way to read its contents is through the dir.iterate() method. This method returns a lightweight, zero-cost iterator (std.fs.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() performs the actual system call to fetch the next entry and returns an error union !?Entry, meaning it can fail with an I/O error at any time (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() takes care of cleanup even on errors.
A crucial detail is that 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, dir.iterate() is a beautifully minimal yet powerful abstraction: it 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 on your machine:
$ zig version
0.15.2
$ zig run ls.zig
README.md .gitignore files .git
home usr bin sbin etc var dev tmp cores
Happy coding in Zig!