This is a quick guide on how to use Zig Writers and Readers. Zig 0.15 introduces std.Io.Reader and std.Io.Writer interface types that sit on top of explicitly managed buffers. No hidden allocations, no magic.

Writers

You provide the buffer yourself:


var buffer: [4096]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buffer);
const output = &writer.interface;

try output.print("hello\n", .{});
try output.flush();

Three things to notice:

  1. You own the buffer. That [4096]u8 lives on your stack. No allocator involved.
  2. .interface gives you the generic type. The writer variable is a concrete file writer. Calling .interface gives you a *std.Io.Writer — a type-erased interface that any function can accept without caring about the underlying implementation.
  3. Flushing is explicit. Nothing hits stdout until you call flush(). Forget it and your output silently vanishes.

Readers

Reading from stdin follows the same pattern. You provide a buffer and get back an interface:


var buffer: [4096]u8 = undefined;
var reader = std.fs.File.stdin().reader(&buffer);
const input = &reader.interface;

To read a line, streamDelimiter streams bytes into a fixed-buffer writer until it hits the delimiter:


var line_buffer: [1024]u8 = undefined;
var line_writer: std.Io.Writer = .fixed(&line_buffer);
const line_size = input.streamDelimiter(&line_writer, '\n') catch |err| {
    if (err == error.EndOfStream) return null;
    return err;
};
std.debug.assert(line_size <= line_buffer.len);
input.toss(1); // discard the newline itself
const line = line_buffer[0..line_size];

streamDelimiter reads bytes from the input and writes them into another writer until it hits the delimiter. toss(1) discards the newline so the next read starts clean. The buffer lives in the caller's scope — you own the memory the slice points to.

Why interfaces matter

*std.Io.Writer and *std.Io.Reader are interfaces — they abstract over the concrete source or destination. This is what makes testing possible without touching real I/O.

Say you have a function that takes a reader and a writer:


pub fn run(input: *std.Io.Reader, output: *std.Io.Writer) !void {
    // read from input, write to output
}

In production, you pass in stdin and stdout:


const io_buffer_size = 4096;

pub fn main() !void {
    var writer_buffer: [io_buffer_size]u8 = undefined;
    var reader_buffer: [io_buffer_size]u8 = undefined;
    var writer = std.fs.File.stdout().writer(&writer_buffer);
    const output = &writer.interface;
    defer output.flush() catch {};
    var reader = std.fs.File.stdin().reader(&reader_buffer);
    const input = &reader.interface;

    try run(input, output);
}

In tests, the same function accepts fixed-buffer readers and writers — no mocking framework needed:


test "run exits on .exit command" {
    var reader: std.Io.Reader = .fixed(".exit\n");
    var output_buffer: [4096]u8 = undefined;
    var writer: std.Io.Writer = .fixed(&output_buffer);

    try run(&reader, &writer);

    const written = output_buffer[0..writer.end];
    try expect(std.mem.indexOf(u8, written, "See ya!") != null);
}

.fixed() creates a reader or writer backed by a byte slice. For readers, the slice is the input data. For writers, the slice captures whatever gets written. Same interface, zero I/O.

Notes

  • Always flush your writer. The safest pattern is defer output.flush() catch {}; right after creating the writer — that way output gets flushed even if you return early on error.
  • undefined is intentional. Zig's undefined is explicitly uninitialized memory. For buffers you're about to fill, that's exactly what you want. No wasted cycles zeroing bytes you're about to overwrite.
  • Buffer sizing matters. Your buffer size is your upper bound. A [1024]u8 line buffer means lines longer than 1024 bytes will cause an error. Put a limit on everything.