Skip to content

Directories and Paths

A file lives inside a directory.

A file lives inside a directory.

A directory is a container for file names and other directories. Many systems call directories “folders,” but in programming, “directory” is the more common term.

A path is a name that tells the operating system where something is.

For example:

hello.txt

is a path.

src/main.zig

is also a path.

/home/alice/project/src/main.zig

is another path.

When your program reads or writes files, it almost always works with paths and directories.

Current Working Directory

Every running program has a current working directory.

This is the directory that relative paths are resolved from.

If your program uses this path:

hello.txt

the operating system interprets it relative to the current working directory.

So if your program is running inside:

/home/alice/project

then:

hello.txt

means:

/home/alice/project/hello.txt

In Zig 0.16 style, you can refer to the current working directory through std.Io.Dir.cwd() when using the newer I/O APIs. Zig 0.16.0 is the current latest official release, and its release notes describe the standard library’s newer std.Io work as one of the major changes.

A typical file operation starts like this:

const std = @import("std");

pub fn main(init: std.process.Init) !void {
    const io = init.io;

    const file = try std.Io.Dir.cwd().openFile(io, "hello.txt", .{});
    defer file.close(io);

    // use file here
}

This means: open hello.txt from the current working directory.

Relative Paths

A relative path is interpreted from some starting directory.

These are relative paths:

hello.txt
src/main.zig
../notes.txt
./data/input.txt

The path:

src/main.zig

means: go into the src directory, then find main.zig.

The path:

../notes.txt

means: go to the parent directory, then find notes.txt.

The path:

./data/input.txt

means: start here, go into data, then find input.txt.

The dot . means the current directory.

The double dot .. means the parent directory.

Relative paths are useful for project files, test data, configuration files, and command-line tools.

Absolute Paths

An absolute path starts from a system root.

On Linux and macOS, an absolute path usually starts with /:

/home/alice/project/hello.txt

On Windows, an absolute path may look like this:

C:\Users\Alice\project\hello.txt

Absolute paths are useful when you need an exact location.

But many programs should avoid hard-coding absolute paths. They make programs less portable.

This is fragile:

/home/alice/project/data.txt

It works only on Alice’s machine, in that exact directory.

This is usually better:

data.txt

or:

data/input.txt

The program can then run from the project directory on different machines.

Path Separators

Different operating systems write paths differently.

Unix-like systems use /:

src/main.zig

Windows commonly uses \:

src\main.zig

Zig’s standard library has path utilities to help with platform differences. For simple examples, you will often see / used in paths because Zig can handle many common cases, but real cross-platform programs should avoid manually stitching paths with string concatenation.

This is suspicious:

const path = "data/" ++ filename;

It assumes /.

A safer design is to use standard library path helpers when building paths dynamically.

Opening a Directory

A directory can be opened much like a file.

You open a directory when you want to work relative to that directory, list entries inside it, or create files inside it.

The current working directory is already available:

const cwd = std.Io.Dir.cwd();

Then you can open files relative to it:

const file = try cwd.openFile(io, "hello.txt", .{});
defer file.close(io);

This style is useful because the directory becomes the base for later operations.

Instead of thinking in raw strings, think in two parts:

the directory you start from

the relative path inside that directory

That is often safer and clearer.

Creating a Directory

Many programs need to create directories.

For example, a command-line tool might create an output directory:

build-output

The shape of the code is:

try std.Io.Dir.cwd().makeDir(io, "build-output");

Directory creation can fail.

The directory might already exist.

The parent path might not exist.

The program might not have permission.

The disk might be read-only.

So directory creation uses try.

A practical program often handles “already exists” separately:

std.Io.Dir.cwd().makeDir(io, "build-output") catch |err| switch (err) {
    error.PathAlreadyExists => {},
    else => return err,
};

This says: if the directory already exists, that is fine. For every other error, return the error.

This is a common Zig pattern.

Creating a File Inside a Directory

Once you have a directory, you can create a file inside it.

const std = @import("std");

pub fn main(init: std.process.Init) !void {
    const io = init.io;

    const cwd = std.Io.Dir.cwd();

    cwd.makeDir(io, "out") catch |err| switch (err) {
        error.PathAlreadyExists => {},
        else => return err,
    };

    const file = try cwd.createFile(io, "out/result.txt", .{});
    defer file.close(io);

    try file.writeAll(io, "created inside out\n");
}

This program creates a directory named out, then creates:

out/result.txt

The important point is that out/result.txt is still a relative path. It is relative to cwd.

Listing Directory Entries

A directory contains entries.

An entry can be a file, a directory, a symbolic link, or something else depending on the operating system.

The exact iterator API can vary across Zig versions, so the safest source for your installed compiler is:

zig std

Conceptually, directory listing looks like this:

const dir = try std.Io.Dir.cwd().openDir(io, "src", .{
    .iterate = true,
});
defer dir.close(io);

var iterator = dir.iterate(io);

while (try iterator.next()) |entry| {
    std.debug.print("{s}\n", .{entry.name});
}

The structure is more important than the exact syntax.

Open the directory.

Ask for iteration support.

Create an iterator.

Call next until it returns no more entries.

Use each entry name.

Directory iteration can fail because the file system can change while you are reading it. A directory can be removed. Permissions can change. A disk can fail. Network file systems can disconnect.

That is why next may need try.

Entry Names Are Not Full Paths

When you list a directory, each entry usually gives you a name, not a full path.

If you are listing:

src

and the entry name is:

main.zig

the full relative path is:

src/main.zig

Do not confuse the two.

Entry name:

main.zig

Path from project root:

src/main.zig

Absolute path:

/home/alice/project/src/main.zig

These are different pieces of information.

Joining Paths

Suppose you have a directory name and a file name:

const dir_name = "src";
const file_name = "main.zig";

You want:

src/main.zig

For quick examples, you might write the path directly. For real code, path joining should use standard library helpers because path rules vary by operating system.

The conceptual operation is:

const path = join(dir_name, file_name);

The result is a valid path for the target platform.

When path construction needs memory, the function may require an allocator. This follows Zig’s rule: allocation should be visible.

A common shape is:

const path = try std.fs.path.join(allocator, &.{ "src", "main.zig" });
defer allocator.free(path);

This creates a joined path and later frees it.

The exact namespace and APIs may differ depending on the Zig version and whether you are using older std.fs APIs or newer std.Io APIs. The principle stays the same: do not build nontrivial paths by careless string concatenation.

Checking File Metadata

Sometimes you need information about a file or directory.

That information is called metadata.

Metadata may include:

file size

file kind

permissions

modification time

A common operation is stat.

For a file:

const stat = try file.stat(io);
std.debug.print("size = {}\n", .{stat.size});

For a path, a directory API may also provide ways to stat an entry.

This is useful when you need to know whether something is a file or directory before processing it.

But be careful: file systems can change between checking and using. A file might exist when you check it and be gone one moment later.

So code still needs error handling at the point of use.

Deleting Files

Deleting a file removes a directory entry.

The conceptual operation is:

try std.Io.Dir.cwd().deleteFile(io, "old.txt");

This can fail.

The file might not exist.

The path might be a directory.

The program might not have permission.

The file might be locked by another process.

For command-line tools, you may want to handle missing files gracefully:

std.Io.Dir.cwd().deleteFile(io, "old.txt") catch |err| switch (err) {
    error.FileNotFound => {},
    else => return err,
};

This says: if the file is already gone, that is acceptable.

Removing Directories

Removing a directory is different from deleting a file.

A directory may need to be empty before it can be removed.

The conceptual operation is:

try std.Io.Dir.cwd().deleteDir(io, "empty-dir");

If the directory contains files, this may fail.

Recursive deletion is more dangerous because it removes a whole tree. Use it carefully. Bugs in recursive deletion can destroy data quickly.

For beginner code, prefer simple deletion first.

Paths Are Data from Outside Your Program

Paths often come from users.

For example:

mytool input.txt

Here, input.txt is user input.

Treat paths carefully.

A user might pass:

../../important-file

or an absolute path:

/etc/passwd

or a path with unusual characters.

This matters especially for servers, archive extractors, package managers, build tools, and programs that write files.

Do not blindly join untrusted paths and write to them.

A safe program should decide which directories it is allowed to access, then validate or normalize paths before using them.

A Small Directory Listing Program

Here is a complete beginner-style example:

const std = @import("std");

pub fn main(init: std.process.Init) !void {
    const io = init.io;

    const dir = try std.Io.Dir.cwd().openDir(io, ".", .{
        .iterate = true,
    });
    defer dir.close(io);

    var iterator = dir.iterate(io);

    while (try iterator.next()) |entry| {
        std.debug.print("{s}\n", .{entry.name});
    }
}

This lists entries in the current directory.

The path "." means “this directory.”

The option .iterate = true means we want to iterate through entries.

The loop keeps asking for the next entry until there are no more.

A Small “Create Output File” Program

This example creates an output directory, then writes a file inside it:

const std = @import("std");

pub fn main(init: std.process.Init) !void {
    const io = init.io;

    const cwd = std.Io.Dir.cwd();

    cwd.makeDir(io, "out") catch |err| switch (err) {
        error.PathAlreadyExists => {},
        else => return err,
    };

    const file = try cwd.createFile(io, "out/message.txt", .{
        .truncate = true,
    });
    defer file.close(io);

    try file.writeAll(io, "hello from Zig\n");
}

After running it, the project directory contains:

out/message.txt

and the file contains:

hello from Zig

Directory APIs Teach a Larger Zig Habit

Directory and path code teaches a larger habit in Zig: be explicit about the base of an operation.

Instead of passing loose path strings everywhere, think about which directory owns the operation.

For example:

const cwd = std.Io.Dir.cwd();

Then operations are relative to that directory:

try cwd.createFile(io, "out/message.txt", .{});

This helps you reason about file system effects.

Where can this program read?

Where can this program write?

Which paths are relative?

Which paths are absolute?

Which operation may fail?

Zig does not remove these questions. It makes them visible.

What You Should Remember

A directory contains files and other directories.

A path names a file system location.

A relative path depends on a starting directory.

An absolute path starts from the system root.

The current working directory is the default base for many relative paths.

Use directory APIs to open, create, list, and delete entries.

Use defer to close opened directories and files.

Use path helpers instead of careless string concatenation.

Treat user-provided paths carefully.

File systems can change while your program runs, so always handle errors at the point where you open, read, write, create, or delete.