Introduction
Prior to Zig version 0.15.1, writing to standard output was more or less straightforward:
const std = @import("std");
pub fn main() !void {
var stdout = std.io.getStdOut().writer();
try stdout.print("did you ever hear the tragedy of darth plagueis the wise?\n", .{});
}
With the release of Zig 0.15.1, however, the I/O subsystem underwent a major redesign, popularly nicknamed Writergate. The standard library's I/O interfaces now use buffered I/O by default. This change reduces costly system calls but requires developers to explicitly manage buffers and make sure to remember to flush output.
What Changed in Zig 0.15.1
In the new system:
- Writers and readers require an explicit buffer.
- Data is written to that buffer first and only flushed to the OS (e.g., stdout) on demand.
- Forgetting to call
.flush()
may result in missing output on the terminal.
From the release notes: “Please use buffering! And don’t forget to flush!”.
Example: Writing with a Buffer
const std = @import("std");
pub fn main() !void {
var stdout_buffer: [1024]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
const stdout = &stdout_writer.interface;
try stdout.print("did you ever hear the tragedy of darth plagueis the wise?\n", .{});
// Required to push buffered output to the terminal
try stdout.flush();
}
Without the final flush()
, nothing may appear on screen.
Why Buffered I/O?
Buffered I/O reduces the number of expensive kernel syscalls. Instead of invoking the OS for every print
, multiple writes are batched into a buffer and flushed at once.
This makes I/O significantly more efficient—especially in performance-sensitive applications.
Writing to Stdout
Printing Formatted Strings
const std = @import("std");
pub fn main() !void {
var stdout_buffer: [256]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
const stdout = &stdout_writer.interface;
try stdout.print("greetings, program, welcome to the {s}, a {s}\n", .{"grid", "digital frontier"});
try stdout.flush();
}
Writing Raw Bytes
const std = @import("std");
pub fn main() !void {
var buf: [128]u8 = undefined;
var w = std.fs.File.stdout().writer(&buf);
const stdout = &w.interface;
try stdout.writeAll("just a raw message ig\n");
try stdout.flush();
}
Unbuffered Output
If you want writes to go directly to the OS without buffering, pass an empty slice (&.{}
):
const std = @import("std");
pub fn main() !void {
var w = std.fs.File.stdout().writer(&.{});
const stdout = &w.interface;
try stdout.print("an unbuffered print!!! :o o7 o7\n", .{});
// flush is a no-op in this case
}
This bypasses buffering but is less efficient.
Passing Writers Around
The new interface design makes it simple to write functions that accept any writer:
const std = @import("std");
fn greet(writer: *std.Io.Writer) !void {
try writer.print("greetings, program. welcome to the {s}, a {s}\n", .{"grid", "digital frontier"});
}
pub fn main() !void {
var buf: [512]u8 = undefined;
var w = std.fs.File.stdout().writer(&buf);
const stdout = &w.interface;
try greet(stdout);
try stdout.flush();
}
This flexibility allows the same function to target stdout, files, sockets, or async streams.
Reading with the New API
Readers also now require explicit buffers and expose new methods such as takeDelimiterExclusive
.
Example: Reading from Stdin
const std = @import("std");
pub fn main() !void {
var stdin_buffer: [512]u8 = undefined;
var stdin_reader_wrapper = std.fs.File.stdin().reader(&stdin_buffer);
const reader: *std.Io.Reader = &stdin_reader_wrapper.interface;
var stdout_buffer: [512]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
const stdout: *std.Io.Writer = &stdout_writer.interface;
try stdout.writeAll("Type something: ");
try stdout.flush();
while (reader.takeDelimiterExclusive('\n')) |line| {
try stdout.print("You typed: {s}\n...\nType something: ", .{line});
try stdout.flush();
} else |err| switch (err) {
error.EndOfStream => {},
error.StreamTooLong => return err,
error.ReadFailed => return err,
}
}
╰─➤ zig run main.zig
Type something: hi there
You typed: hi there
...
Type something: oh wow
You typed: oh wow
...
Type something: this is basically a shell
You typed: this is basically a shell
...
Type something: an echo shell?
You typed: an echo shell?
...
Type something:
This example implements a simple echo shell that reads user input line by line.
Pitfalls and Best Practices
-
Always flush after writes unless you’re deliberately leaving data in the buffer. Remember, Zig's
Reader
/Writer
are both ring buffers. -
Use stable backing objects: the wrapper (
stdout_writer
,stdin_reader_wrapper
) must outlive the interface pointer (&.interface
). -
Don’t copy interfaces incorrectly: these rely on
@fieldParentPtr
; mishandling can cause undefined behavior. -
Handle long lines:
takeDelimiterExclusive
will returnerror.StreamTooLong
if input exceeds buffer size. - Unbuffered writers: only use when absolutely necessary; buffering is almost always better.
Conclusion
Zig 0.15.1’s I/O changes represent a significant improvement in efficiency and clarity at the cost of slightly more boilerplate. Developers now:
- Provide explicit buffers
- Remember to flush
- Write code that can flexibly handle any reader/writer
For more details, see:
- Zig's New Async I/O
- Zig's new Writer
- I'm too dumb for Zig's new IO interface
- I’m too dumb for Zig’s new IO interface - discussion on ziggit.dev
- from r/zig - when do i need to flush ? – help understanding 0.15.1 change for Writers
- zig 0.15.1 release notes - Upgrading std.io.getStdOut().writer().print()
- Writergate
- Zig breaking change – Initial Writergate
- Zig 0.15.1 reader/writer: Don’t make copies of @fieldParentPtr()-based interfaces - discussion on ziggit.dev
With these tools, Ziguanas gain both performance and flexibility—so long as they don’t forget to flush.
Top comments (0)