⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.2122+3e15e99e6What's newOn an older Zig?

Readers and Writers

const std = @import("std");
const expect = std.testing.expect;

test "iterate lines with takeDelimiter" {
    var reader: std.Io.Reader = .fixed("line one\nline two\n");

    // `takeDelimiter` consumes the delimiter and returns null at the end,
    // which is what you want for a line loop.
    var count: usize = 0;
    while (try reader.takeDelimiter('\n')) |line| {
        count += 1;
        try expect(line.len == 8);
    }
    try expect(count == 2);
}

test "the Exclusive variant leaves the delimiter behind" {
    var reader: std.Io.Reader = .fixed("line one\nline two\n");

    const first = try reader.takeDelimiterExclusive('\n');
    try expect(std.mem.eql(u8, first, "line one"));

    // The '\n' has NOT been consumed, so the next call sees it immediately
    // and returns an empty slice. Use `takeDelimiter` to loop over lines,
    // or `toss(1)` to step past the delimiter yourself.
    const second = try reader.takeDelimiterExclusive('\n');
    try expect(second.len == 0);

    reader.toss(1);
    const third = try reader.takeDelimiterExclusive('\n');
    try expect(std.mem.eql(u8, third, "line two"));
}

test "take a fixed number of bytes" {
    var reader: std.Io.Reader = .fixed("abcdef");
    const chunk = try reader.take(3);
    try expect(std.mem.eql(u8, chunk, "abc"));
}

test "write into a caller-owned buffer" {
    var buf: [32]u8 = undefined;
    var writer: std.Io.Writer = .fixed(&buf);

    try writer.writeAll("hello");
    try writer.print(" {d}", .{42});

    try expect(std.mem.eql(u8, writer.buffered(), "hello 42"));
}

test "allocating writer grows as needed" {
    const gpa = std.testing.allocator;
    var writer: std.Io.Writer.Allocating = .init(gpa);
    defer writer.deinit();

    for (0..5) |i| try writer.writer.print("{d},", .{i});
    try expect(std.mem.eql(u8, writer.written(), "0,1,2,3,4,"));
}

test "a fixed writer reports when it runs out" {
    var buf: [4]u8 = undefined;
    var writer: std.Io.Writer = .fixed(&buf);
    try std.testing.expectError(error.WriteFailed, writer.writeAll("too long"));
}

Zig 0.15 replaced the old generic Reader/Writer with non-generic std.Io.Reader and std.Io.Writer. Two things changed that matter every day:

  1. The buffer belongs to the interface, not the implementation. You supply it, so there is no hidden allocation and you control the size.
  2. Writers are buffered, so you must flush. Forgetting is the single most common mistake with the new API.

“Non-generic” is the word doing the work in that first sentence. The old Writer was a type parameter. Every function taking one was therefore generic and got compiled again per writer type, and a struct that wanted to store one had to be generic too. std.Io.Writer is a concrete type holding a pointer and a vtable, so a function takes *std.Io.Writer and that is the end of it. Compile times drop, error messages get shorter, and writers become values you can put in a field.

Constructing them

var reader: std.Io.Reader = .fixed("some bytes");
var writer: std.Io.Writer = .fixed(&buf);

var growable: std.Io.Writer.Allocating = .init(gpa);

For files, file.writerStreaming(io, &buf) gives you a File.Writer whose .interface field is the std.Io.Writer to use.

That .interface field is the pattern to recognise. The concrete type holds the state that a file writer needs, and embeds the generic interface as a field, so you pass &file_writer.interface to anything that takes a writer. Getting a compile error about File.Writer where a *std.Io.Writer was expected almost always means a missing .interface.

Flush, and where it belongs

var buf: [4096]u8 = undefined;
var fw = std.Io.File.stdout().writerStreaming(io, &buf);
defer fw.interface.flush() catch {};

try fw.interface.print("{d}\n", .{42});

Nothing reaches the file until the buffer fills or you flush. A program that prints and then exits without flushing prints nothing, which is a confusing first encounter and the reason the defer goes directly under the construction.

Note the buffer size is yours to pick, and it is a real decision. One byte means a syscall per write. Four kilobytes is a page and a sensible default. Larger helps for bulk output and does nothing for a program that prints six lines.

Reading lines: mind the variant

This one catches people, including an earlier draft of this page:

MethodConsumes delimiterAt end of stream
takeDelimiteryesreturns null
takeDelimiterExclusivenoerror
takeDelimiterInclusiveyes, and includes iterror

So takeDelimiterExclusive called twice on "a\nb\n" returns "a" and then an empty slice, because the \n is still there. For a line loop you want:

while (try reader.takeDelimiter('\n')) |line| { ... }

Use toss(n) to skip bytes manually if you do use the exclusive form.

The slice a take returns points into the reader’s buffer and is valid until the next read. Keeping a line means copying it. That is the same borrowed-view rule as ArrayList.items, and it is what makes reading a large file with a small buffer possible at all.

Running out of room

A .fixed writer fails with error.WriteFailed when the buffer is full. It does not truncate silently.

On the reading side, a line longer than the reader’s buffer is error.StreamTooLong rather than a partial line. Same reason: the interface will not hand you something that looks complete and is not. If input line length is unbounded and untrusted, that error is your length limit, and treating it as a protocol violation is usually correct.

Why one interface

Because everything becomes composable. A parser written against *std.Io.Reader is tested against .fixed("literal input") and deployed against a socket without changing. A formatter written against *std.Io.Writer writes to a file, a buffer, or a hash. That is the reason the networking chapters can run in a browser: the protocol code takes bytes and returns bytes, and only the chapters genuinely about sockets need one.