Skip to lesson

learningzig.org / intermediate / 20-file-io · lesson 20 of 25

TL;DR

Read and write files in Zig with std.Io.Dir — open file handles, use buffered readers and writers, manage directories, and handle I/O errors explicitly.

Key concepts

  • zig file io
  • zig read file
  • zig write file
  • zig std.Io.Dir
  • zig buffered io

File I/O

Zig's approach to file I/O is explicit, safe, and composable. There are no hidden exceptions, no global error state, and no silent failures. Every operation that might fail returns an error union, and every resource that needs cleanup has a clear ownership model. In Zig 0.16 the filesystem lives under std.Io.Dir, and every operation that touches the outside world takes an Io — the same interface you thread through concurrent code — so you always see where I/O happens. The reader and writer interfaces work the same way whether you are writing to a file, a socket, or an in-memory buffer.

Opening and Writing Files

The entry point for all filesystem operations is the current working directory: std.Io.Dir.cwd(). From there you can create files, open existing ones, and navigate directories. Every call also takes the io value that Zig 0.16 hands your main through std.process.Init. Creating a file and writing to it requires two steps — opening the file handle, then writing through it.

const std = @import("std");

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

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

    try file.writeStreamingAll(io, "Hello from Zig!\n");
    try file.writeStreamingAll(io, "File I/O is explicit and safe.\n");

    std.debug.print("File written successfully.\n", .{});
}

createFile accepts the io value, the path, and a flags struct. The empty .{} uses all defaults — it creates the file if it does not exist and truncates it if it does. The defer file.close(io) pattern ensures the handle is released even if a later operation fails. writeStreamingAll writes the entire slice before returning, unlike lower-level write calls which may write less than the full buffer.

Reading Files and Allocating Content

Reading a file requires knowing how large it might be. For small files where you want the entire contents at once, readFileAlloc opens the path, reads until EOF, and returns a freshly allocated slice. You provide an allocator and an Io.Limit as a safety guard against reading arbitrarily large files into memory. The allocator comes ready-made: std.process.Init hands your main a general-purpose allocator in init.gpa.

const std = @import("std");

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

    const cwd = std.Io.Dir.cwd();
    const contents = try cwd.readFileAlloc(io, "greeting.txt", allocator, .limited(1024 * 1024)); // 1 MB max
    defer allocator.free(contents);

    std.debug.print("Read {d} bytes:\n{s}", .{ contents.len, contents });
}

Notice the symmetry: every allocation has a corresponding defer allocator.free(...). Zig does not manage these resources for you — but defer makes the cleanup pattern clean and reliable. readFileAlloc opens the file read-only and closes it for you. When you need the handle itself — for example to read and write the same file — call cwd.openFile(io, path, .{ .mode = .read_write }) and pair it with defer file.close(io).

Buffered I/O for Performance

Writing or reading one byte at a time causes excessive system calls. Buffered I/O solves this by accumulating data in memory and flushing it in larger chunks. In Zig 0.16 buffering is built into the writer itself: file.writer(io, &buffer) returns a File.Writer that batches writes through the buffer slice you hand it. You format through its .interface, and the underlying writes are batched automatically.

const std = @import("std");

pub fn main(init: std.process.Init) !void {
    const io = init.io;
    const cwd = std.Io.Dir.cwd();
    const file = try cwd.createFile(io, "numbers.txt", .{});
    defer file.close(io);

    var buffer: [1024]u8 = undefined;
    var file_writer = file.writer(io, &buffer);
    const writer = &file_writer.interface;

    var i: u32 = 1;
    while (i <= 10) : (i += 1) {
        try writer.print("Line {d}: square is {d}\n", .{ i, i * i });
    }

    try writer.flush(); // Must flush before closing
    std.debug.print("Wrote 10 lines with buffered I/O.\n", .{});
}

The flush() call is mandatory — data sitting in the buffer is not written to disk until you flush explicitly or the buffer fills up. Forgetting to flush is a common mistake that produces truncated files with no error at the call site. Reading is symmetric: file.reader(io, &buffer) returns a File.Reader whose .interface reads through the same buffer, reducing system calls when processing data in small increments.

Reading Line by Line

Many real-world file processing tasks work one line at a time — parsing CSV, reading config files, processing logs. Zig 0.16's reader interface provides takeDelimiter for this pattern. It returns ?[]u8, yielding null at EOF, which makes a while loop natural.

const std = @import("std");

pub fn main() !void {
    // An in-memory reader — replace with a File.Reader over an open file in real code.
    const data = "apple\nbanana\ncherry\ndate\n";
    var reader = std.Io.Reader.fixed(data);

    var line_count: u32 = 0;
    while (try reader.takeDelimiter('\n')) |line| {
        line_count += 1;
        std.debug.print("Line {d}: {s}\n", .{ line_count, line });
    }

    std.debug.print("\nTotal lines: {d}\n", .{line_count});
}

Each returned line is a slice that borrows from the reader's own buffer, so there is nothing to free — but the slice is only valid until the next read call, so copy it if you need to keep it. takeDelimiter excludes the delimiter and treats end-of-stream as a final line. In production code, replace std.Io.Reader.fixed with a File.Reader over an open file — the reader interface is identical.

Working with Paths

Before you open a file you often need to pick apart a path — extract the filename, find the directory, or check an extension. std.fs.path provides these operations as pure string functions: they inspect the path you pass without touching the filesystem, so they never fail or allocate. basename returns the final component, dirname the parent directory (or null when there is none), extension the trailing .ext, and stem the filename with its extension stripped.

const std = @import("std");

pub fn main() !void {
    const full_path = "/home/user/documents/report.txt";

    std.debug.print("Basename:  {s}\n", .{std.fs.path.basename(full_path)});
    std.debug.print("Directory: {s}\n", .{std.fs.path.dirname(full_path) orelse "."});
    std.debug.print("Extension: {s}\n", .{std.fs.path.extension(full_path)});
    std.debug.print("Stem:      {s}\n", .{std.fs.path.stem(full_path)});

    // These are pure string operations — they work on any path, real or not.
    const paths = [_][]const u8{
        "/home/user/photo.jpg",
        "/tmp/data.csv",
        "relative/path/file.zig",
    };

    std.debug.print("\nPath analysis:\n", .{});
    for (paths) |path| {
        std.debug.print("  {s}\n", .{path});
        std.debug.print("    dir:  {s}\n", .{std.fs.path.dirname(path) orelse "(none)"});
        std.debug.print("    name: {s}\n", .{std.fs.path.basename(path)});
        std.debug.print("    ext:  {s}\n", .{std.fs.path.extension(path)});
    }
}

Because these functions only slice into the string you gave them, the results borrow from that original string — they are valid only as long as the input path is. To join paths rather than take them apart, std.fs.path.join builds a new path from components using the platform's separator, and it does allocate, so it takes an allocator and its result must be freed.

Try It Yourself

Write a program that counts the number of words in a text source. Words are separated by whitespace. Use the std.ascii.isWhitespace helper and a simple state machine to detect word boundaries.

const std = @import("std");

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

    // Swap this for a real file:
    // const contents = try std.Io.Dir.cwd().readFileAlloc(
    //     init.io, "essay.txt", allocator, .limited(10 * 1024 * 1024));
    const text = "the quick brown fox\njumps over the lazy dog\n";
    var reader = std.Io.Reader.fixed(text);
    const contents = try reader.allocRemaining(allocator, .limited(1024 * 1024));
    defer allocator.free(contents);

    var word_count: u32 = 0;
    var char_count: u32 = 0;
    var line_count: u32 = 0;
    var in_word = false;

    for (contents) |c| {
        char_count += 1;
        if (c == '\n') line_count += 1;
        if (std.ascii.isWhitespace(c)) {
            in_word = false;
        } else if (!in_word) {
            in_word = true;
            word_count += 1;
        }
    }

    std.debug.print("Characters: {d}\n", .{char_count});
    std.debug.print("Lines:      {d}\n", .{line_count});
    std.debug.print("Words:      {d}\n", .{word_count});
}

Try extending this to report the average word length, or to read from an actual file path passed as a command-line argument using std.process.Args.

Key Takeaways

  • std.Io.Dir.cwd() is the entry point for filesystem operations; every call takes the io from std.process.Init
  • createFile writes and openFile reads; readFileAlloc opens, reads to EOF, and closes in one call
  • defer file.close(io) and defer allocator.free(...) keep resource cleanup paired with acquisition — never skip them
  • readFileAlloc reads an entire file into a caller-owned slice; always provide an allocator and an Io.Limit
  • file.writer(io, &buffer) and file.reader(io, &buffer) build in the buffer — the writer's .interface batches writes for performance
  • Always call writer.flush() before closing a buffered writer — unflushed data is silently discarded
  • takeDelimiter returns ?[]u8 with null at EOF, making line-by-line loops natural; each line borrows the reader's buffer
  • std.Io.Reader.fixed lets you test I/O logic in memory without touching the real filesystem

Pro Tip: Prefer std.Io.Reader.fixed (and std.Io.Writer.fixed) when writing unit tests for functions that perform I/O — it gives you an in-memory reader or writer with the exact same interface as a real file. Your logic becomes fully testable without creating temporary files, and tests run faster because there are no system calls involved.

Next Steps

Now that you can read and write data, you'll need somewhere to put it. Next, you'll learn how to build and use common data structures in Zig -- from dynamic arrays and hash maps to writing your own generic collections.

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.