Zig's new Writer
Jul 17, 2025
As you might have heard, Zig's Io
namespace is being reworked. Eventually, this will mean the re-introduction of async. As a first step though, the Writer and Reader interfaces and some of the related code have been revamped.
This post is written based on a mid-July 2025 development release of Zig. It doesn't apply to Zig 0.14.x (or any previous version) and is likely to be outdated as more of the Io namespace is reworked.
Not long ago, I wrote a blog post which tried to explain Zig's Writers. At best, I'd describe the current state as "confusing" with two writer interfaces while often dealing with anytype
. And while anytype
is convenient, it lacks developer ergonomics. Furthermore, the current design has significant performance issues for some common cases.
The new Writer
interface is std.Io.Writer
. At a minimum, implementations have to provide a drain
function. Its signature looks like:
fn drain(w: *Writer, data: []const []const u8, splat: usize) Error!usize
You might be surprised that this is the method a custom writer needs to implemented. Not only does it take an array of strings, but what's that splat
parameter? Like me, you might have expected a simpler write
method:
fn write(w: *Writer, data: []const u8) Error!usize
It turns out that std.Io.Writer
has buffering built-in. For example, if we want a Writer
for an std.fs.File
, we need to provide the buffer:
var buffer: [1024]u8 = undefined;
var writer = my_file.writer(&buffer);
Of course, if we don't want buffering, we can always pass an empty buffer:
var writer = my_file.writer(&.{});
This explains why custom writers need to implement a drain
method, and not something simpler like write
.
The simplest way to implement drain
, and what a lot of the Zig standard library has been upgraded to while this larger overhaul takes place, is:
fn drain(io_w: *Writer, data: []const []const u8, splat: usize) !usize {
_ = splat;
const self: *@This() = @fieldParentPtr("interface", io_w);
return self.writeAll(data[0]) catch return error.WriteFailed;
}
We ignore the splat
parameter, and just write the first value in data
(data.len > 0
is guaranteed to be true). This turns drain
into what a simpler write
method would look like. Because we return the length of bytes written, std.Io.Writer
will know that we potentially didn't write all the data and call drain
again, if necessary, with the rest of the data.
If you're confused by the call to @fieldParentPtr
, check out my post on the upcoming linked list changes.
The actual implementation of drain
for the File
is a non-trivial ~150 lines of code. It has platform-specific code and leverages vectored I/O where possible. There's obviously flexibility to provide a simple implementation or a more optimized one.
Much like the current state, when you do file.writer(&buffer)
, you don't get an std.Io.Writer
. Instead, you get a File.Writer
. To get an actual std.Io.Writer
, you need to access the interface
field. This is merely a convention, but expect it to be used throughout the standard, and third-party, library. Get ready to see a lot of &xyz.interface
calls!
This simplification of File
shows the relationship between the three types:
pub const File = struct {
pub fn writer(self: *File, buffer: []u8) Writer{
return .{
.file = self,
.interface = std.Io.Writer{
.buffer = buffer,
.vtable = .{.drain = Writer.drain},
}
};
}
pub const Writer = struct {
file: *File,
interface: std.Io.Writer,
fn drain(io_w: *Writer, data: []const []const u8, splat: usize) !usize {
const self: *Writer = @fieldParentPtr("interface", io_w);
}
}
}
The instance of File.Writer
needs to exist somewhere (e.g. on the stack) since that's where the std.Io.Writer
interface exists. It's possible that File
could directly have an writer_interface: std.Io.Writer
field, but that would limit you to one writer per file and would bloat the File
structure.
We can see from the above that, while we call Writer
an "interface", it's just a normal struct. It has a few fields beyond buffer
and vtable.drain
, but these are the only two with non-default values; we have to provide them. The Writer
interface implements a lot of typical "writer" behavior, such as a writeAll
and print
(for formatted writing). It also has a number of methods which only a Writer
implementation would likely care about. For example, File.Writer.drain
has to call consume
so that the writer's internal state can be updated. Having all of these functions listed side-by-side in the documentation confused me at first. Hopefully it's something the documentation generation will one day be able to help disentangle.
The new Writer
has taken over a number of methods. For example, std.fmt.formatIntBuf
no longer exists. The replacement is the printInt
method of Writer
. But this requires a Writer
instance rather than the simple []u8
previous required.
It's easy to miss, but the Writer.fixed([]u8) Writer
function is what you're looking for. You'll use this for any function that was migrating to Writer
and used to work on a buffer: []u8
.
While migrating, you might run into the following error: no field or member function named 'adaptToNewApi' in '...'. You can see why this happens by looking at the updated implementation of std.fmt.format
:
pub fn format(writer: anytype, comptime fmt: []const u8, args: anytype) !void {
var adapter = writer.adaptToNewApi();
return adapter.new_interface.print(fmt, args) catch |err| switch (err) {
error.WriteFailed => return adapter.err.?,
};
}
Because this functionality was moved to std.Io.Writer
, any writer
passed into format
has to be able to upgrade itself to the new interface. This is done, again only be convention, by having the "old" writer expose an adaptToNewApi
method which returns a type that exposes a new_interface: std.Io.Writer
field. This is pretty easy to implement using the basic drain
implementation, and you can find a handful of examples in the standard library, but it's of little help if you don't control the legacy writer.
I'm hesitant to provide opinion on this change. I don't understand language design. However, while I think this is an improvement over the current API, I keep thinking that adding buffering directly to the Writer
isn't ideal.
I believe that most languages deal with buffering via composition. You take a reader/writer and wrap it in a BufferedReader or BufferedWriter. This approach seems both simple to understand and implement while being powerful. It can be applied to things beyond buffering and IO. Zig seems to struggle with this model. Rather than provide a cohesive and generic approach for such problems, one specific feature (buffering) for one specific API (IO) was baked into the standard library. Maybe I'm too dense to understand or maybe future changes will address this more holistically.