Skip to content
Make room for learningYour learning studio
Course outline

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.

Read that paragraph as a snapshot of 0.16.0, not as settled ground. Zig's I/O surface has been rewritten in each of the last two releases: 0.15.1 replaced the old reader and writer interfaces wholesale — the change its contributors nicknamed "Writergate" — and 0.16.0 then made every operation that touches the outside world take an explicit Io instance, which is why createFile(io, ...) and file.close(io) below carry an argument that older code does not. Expect the same surface to move again at 0.17. This matters more here than anywhere else in the track: everything you learn about why buffering needs a flush and who owns a returned slice carries forward unchanged, but the exact call shapes on this page are pinned to 0.16.0, and an I/O snippet you find in a blog post or an older language reference will usually not compile. Check the version it was written for before you borrow from it.

What You'll Learn

By the end of this lesson you will write a program that opens a file, pushes a batch of records into it through a buffered writer, and then reads them back to prove the bytes actually landed on disk. That last step is the whole point rather than a flourish: a write that is never flushed returns success at every call site and leaves you holding an empty file.

This is the milestone the capstone's Record Analyzer stands on — the analyzer reads a pipe-delimited data file from disk and writes its report back out, so everything it claims depends on getting bytes in and out correctly. You arrive here able to model a row as a struct and walk a slice of them (Arrays and Structs) and to say who owns a piece of heap memory (Memory and Allocators); what is new is that the rows now come from outside the program, and so does the place they end up.

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.

zig
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.

Note that the snippet below writes greeting.txt before reading it. Every playground snippet runs in a fresh sandbox with an empty working directory, so a program that only reads would fail with error.FileNotFound — a snippet that touches the filesystem has to create its own input. The write is two lines of setup; the read is the part to study.

When a read does fail, Zig gives you a specific error value rather than a generic exception, and reading one has a short routine. Let a missing file propagate out of main with try and the program exits non-zero after printing error: FileNotFound followed by an error return trace — a stack of file:line frames ending at your call site, so the last frame names which read failed, not merely that a read did. That is step one: read the trace bottom-up to find your line. Step two is to decide whether this particular failure is one you can answer. try says no, hand it to my caller; catch |err| switch (err) { error.FileNotFound => ..., else => ... } says yes, and lets you fall back to a default path or create the file. Step three is to check the error set rather than guess: hover the function or read its signature, because the set is part of the return type and lists every failure it can produce — error.FileNotFound, error.AccessDenied, error.IsDir and so on. Guessing which errors a call can return is the one step to skip; the type already tells you.

zig
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();

    // Setup: create the file this snippet then reads. The block scope closes
    // the handle before we reopen the path for reading.
    {
        const file = try cwd.createFile(io, "greeting.txt", .{});
        defer file.close(io);
        try file.writeStreamingAll(io, "Hello from Zig!\n");
    }

    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).

Before moving on, settle what readFileAlloc hands back. Predict the answer, then check it.

Predict

This reads a 11-byte file twice. Predict what the two printed lengths are, and whether removing either defer would matter.

zig
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 file = try cwd.createFile(io, "notes.txt", .{});
  file.close(io);
  const f2 = try cwd.openFile(io, "notes.txt", .{ .mode = .read_write });
  try f2.writeStreamingAll(io, "hello world");
  f2.close(io);

  const first = try cwd.readFileAlloc(io, "notes.txt", allocator, .limited(1024));
  defer allocator.free(first);

  const second = try cwd.readFileAlloc(io, "notes.txt", allocator, .limited(1024));
  defer allocator.free(second);

  std.debug.print("{d} {d}\n", .{ first.len, second.len });
}

Both print 11, and both defers are load-bearing. readFileAlloc opens the path, allocates a slice sized to the contents, reads to EOF, closes the file, and hands you ownership of the memory. Two calls means two independent allocations; drop either free and that one leaks. There is no hidden cache and no shared read position — the function's only lasting effect is the slice it returns to you.

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.

zig
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. 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.

That mandatory flush() is the single most common buffered-I/O bug in Zig, and it is worth meeting as a failure rather than as a warning. Every call below succeeds — no try returns an error — and the program still fails its own check. Diagnose it before you change anything.

Debug

This program writes three rows through a buffered writer, then reads the file back and checks it is not empty. Every write succeeds and no error is returned, yet the check fails. Predict what the file actually contains, then fix it so the program prints the three rows.

zig
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 file = try cwd.createFile(io, "totals.txt", .{});
      defer file.close(io);

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

      var n: u32 = 1;
      while (n <= 3) : (n += 1) {
          try writer.print("row {d}\n", .{n});
      }
  }

  const contents = try cwd.readFileAlloc(io, "totals.txt", allocator, .limited(1024));
  defer allocator.free(contents);

  if (contents.len == 0) {
      std.debug.print("CHECK FAILED: totals.txt is empty (0 bytes on disk)\n", .{});
      return error.CheckFailed;
  }
  std.debug.print("totals.txt holds {d} bytes:\n{s}", .{ contents.len, contents });
}

Expected output:

totals.txt holds 18 bytes:
row 1
row 2
row 3

The three rows never left memory. writer.print batched them into the 1024-byte buffer, 18 bytes never filled it, and when the block ended defer file.close(io) closed the handle and the buffered bytes went away with the writer. No call failed, so no try fired — the file exists, is perfectly valid, and is zero bytes long. Forgetting to flush is a common mistake precisely because it produces truncated files with no error at the call site. Adding try writer.flush(); after the loop, before the scope ends and the defer closes the file, fixes it.

Now assemble that chain yourself, in the order that actually gets bytes onto disk.

Arrange the code

A stack buffer is already declared above these lines as var buffer: [256]u8 = undefined. The seven lines below then create a file, wrap it in a buffered writer over that buffer, write one formatted line, push it to disk, and close the handle. The pieces are shuffled, and the close is written out explicitly here rather than deferred so that its position is part of the puzzle. Reorder them so the file ends up holding the nine bytes of that line — then answer the question the order is really testing: two adjacent swaps near the end still compile and still exit zero, and each one loses the data in a different way. Which two, and what does each leave behind?

  1. try writer.print("total={d}\n", .{42});
  2. var file_writer = file.writer(io, &buffer);
  3. try writer.flush();
  4. const file = try cwd.createFile(io, "report.txt", .{});
  5. const cwd = std.Io.Dir.cwd();
  6. file.close(io);
  7. const writer = &file_writer.interface;

The first four positions are pinned by the compiler — each line consumes a name the one above it introduced. The last three are not, and that is the point: swapping print with flush flushes an empty buffer and leaves a valid zero-byte file, while swapping flush with close gets you error.WriteFailed. Both compile. flush() goes last on the success path while the handle is still open, and the only proof a write landed is reading the file back.

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.

zig
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.

zig
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.

That borrow-versus-own distinction is not new; it is the same one earlier lessons drilled. Check that it transfers.

Recall

Without scrolling up: three functions in this lesson hand you a []const u8 — basename, readFileAlloc, and takeDelimiter. Slices and Iteration taught that a slice is a pointer plus a length that borrows its backing memory, and Memory and Allocators taught that anything an allocator produced must be freed. Which row correctly says who owns each result?

So the signature answers the ownership question, not the type. basename takes no allocator and borrows your path string. readFileAlloc takes an allocator, so it allocated and you must free. takeDelimiter takes neither and borrows the reader's own buffer, valid only until the next read. A []const u8 is just a pointer and a length — it never tells you who owns the bytes.

The chain you just ordered is not specific to files. Check which part of it carries.

Transfer

In this lesson you wrapped a file handle in a buffered writer and had to call flush before the bytes reached disk. Rust's std::io::BufWriter over a File behaves the same way, and its documentation warns that dropping one discards any buffered data if the flush fails. Which statement names the mechanism the two share, rather than a coincidence of naming?

Try It Yourself

Reading about file I/O is not the same as moving bytes through it. This is a build task: a small program that reports its own pass/fail. It is the shape of the capstone's record reader in miniature — parse lines out of a text source, total a column, then write a report file and read it back to prove the bytes landed. Two functions are stubbed out. Run it as-is and it fails immediately, naming the first stub that is still empty. Implement each until it prints All checks passed.

The two stubs use exactly what this lesson taught: std.Io.Reader.fixed plus takeDelimiter to walk lines, and a buffered file.writer(io, &buffer) with a flush() on the success path. The starter has the stubs and the checks; you write only the bodies. Nothing above spells out both answers, so you will have to assemble them.

Build

Finish the build. Two functions are stubbed out and the checks below them fail until each does its job. Run it as-is to see which check fails first, decide what that function is missing, then implement both until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

zig
const std = @import("std");

fn expect(ok: bool, comptime msg: []const u8) !void {
  if (!ok) {
      std.debug.print("CHECK FAILED: {s}\n", .{msg});
      return error.CheckFailed;
  }
}

const Stats = struct { lines: u32, total: u32 };

// TODO 1: walk `text` line by line and total the number after each comma.
//   Input looks like "apples,3\nbananas,5\n" — one record per line,
//   a name, a comma, then a count.
//   Skip empty lines. Return error.MissingComma if a line has no comma.
//   Build a std.Io.Reader over the text with std.Io.Reader.fixed and use
//   takeDelimiter('\n'), which yields null at end of input.
//   summarize("apples,3\nbananas,5\n") -> Stats{ .lines = 2, .total = 8 }
fn summarize(text: []const u8) !Stats {
  // your code here
  _ = text;
  return .{ .lines = 0, .total = 0 };
}

// TODO 2: write exactly one line to `path`, through a BUFFERED writer:
//   "lines=<lines> total=<total>\n"
//   Create the file with std.Io.Dir.cwd().createFile, pair it with a
//   defer close, hand file.writer(io, &buffer) a stack buffer, print
//   through its .interface — and make sure the bytes actually reach disk.
fn writeReport(init: std.process.Init, path: []const u8, stats: Stats) !void {
  // your code here
  _ = init;
  _ = path;
  _ = stats;
}

// --- Build checks: Do not edit below this line. ---
pub fn main(init: std.process.Init) !void {
  const data = "apples,3\nbananas,5\ncherries,12\n";

  const stats = try summarize(data);
  try expect(stats.lines == 3, "summarize should count 3 records");
  try expect(stats.total == 20, "summarize should total 3 + 5 + 12 = 20");

  const empty = try summarize("");
  try expect(empty.lines == 0 and empty.total == 0, "summarize of an empty input should be all zeros");

  if (summarize("no-comma-here\n")) |_| {
      try expect(false, "a line without a comma should return error.MissingComma");
  } else |err| {
      try expect(err == error.MissingComma, "a line without a comma should return error.MissingComma");
  }

  try writeReport(init, "summary.txt", stats);
  const back = try std.Io.Dir.cwd().readFileAlloc(init.io, "summary.txt", init.gpa, .limited(1024));
  defer init.gpa.free(back);
  try expect(back.len > 0, "summary.txt should not be empty — did the buffered writer reach disk?");
  try expect(std.mem.eql(u8, back, "lines=3 total=20\n"), "summary.txt should hold exactly \"lines=3 total=20\\n\"");

  std.debug.print("All checks passed.\n", .{});
  std.debug.print("summary.txt: {s}", .{back});
}

Expected output:

All checks passed.
summary.txt: lines=3 total=20

Once it passes, try two variations and predict each before running:

  1. Remove the flush. Delete try writer.flush(); from writeReport and predict which check fires. The back.len > 0 check fails with CHECK FAILED: summary.txt should not be empty — the file gets created and closed with all 17 bytes still sitting in the stack buffer. Nothing else in the program changes, which is the whole lesson about unflushed writers: the failure surfaces only where you read the bytes back.
  2. Feed summarize a trailing partial line. Change data to "apples,3\nbananas,5\ncherries,12" — same records, no final newline — and predict the totals. They are unchanged (lines = 3, total = 20) and every check still passes, because takeDelimiter treats end-of-stream as terminating the final line rather than discarding it. This changes nothing observable, which is the point: your loop is already correct for files that do not end in a newline.

Capstone milestone

Milestone — record parsing. The capstone's Record Analyzer reads a delimited text file from disk, walks it line by line, and turns each line into a record, then writes a report back out. The build task above is that pipeline in miniature. Confirm you can move bytes in and out of a file safely.

Hint: You don't need the full analyzer yet — this confirms the file-handling discipline it is built on. There, the same readFileAlloc / takeDelimiter / buffered-write-plus-flush sequence loads the input records and emits the report.

  • Opened a file through std.Io.Dir.cwd() and paired it with a defer close(io)
  • Read a file's contents into caller-owned memory with readFileAlloc and freed it with a matching defer
  • Walked a text source line by line with takeDelimiter, handling the null-at-EOF case
  • Wrote through a buffered writer and called flush() before the file was closed
  • Verified the written bytes by reading the file back rather than trusting the write

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.

Next lesson

Data Structures

Use Zig data structures — build dynamic collections with std.ArrayList, store key-value pairs in std.HashMap, and create your own generic types.

25 min