Writergate is the informal name for Zig’s I/O interface overhaul that began in late 2023 and culminated in August 2025 with the complete removal of GenericWriter, GenericReader, AnyWriter, and AnyReader. If you’ve touched Zig I/O code recently, you’ve felt the impact.
The old API used generic types with type parameters:
// Old (removed)
const stdout = std.io.getStdOut();
const writer = stdout.writer();
try writer.print("Hello {s}\n", .{"world"});
The new API uses concrete types with vtables and explicit buffering:
// New (0.15+)
const stdout = std.fs.File.stdout();
var buffer: [4096]u8 = undefined;
var file_writer = stdout.writer(&buffer);
const writer = &file_writer.interface;
defer writer.flush() catch {};
try writer.print("Hello {s}\n", .{"world"});
The breaking changes:
std.io became std.IoThe old generic design poisoned APIs: any function accepting a writer became generic, which forced all containing structs to become generic. Andrew Kelley’s Writergate PR describes the old interface as “poisoning structs that contain them”. I’ve seen this pattern infect entire codebases: one anytype parameter spreads until half your library is generic. It limited API reusability and hurt compile times.
The follow-up in Zig 0.16 treats I/O like memory allocation: code depends on an Io instance the same way it depends on an Allocator. This enables:
Io vtable includes async, await, and cancel primitives. Same code works with thread pools today, io_uring or kqueue as those backends mature.anyerror everywhere, backend operations carry specific error sets; the Writer/Reader interfaces expose a compact WriteFailed/ReadFailed, with details kept on the concrete implementation.The new system has three levels:
Io (Backend) ← Threaded, Evented, Uring... (0.16)
↓
Io.Writer / Io.Reader ← drain, stream, flush, rebase
↓
File.Writer / File.Reader ← Concrete implementations
Custom writers embed the interface and recover the parent via @fieldParentPtr:
pub const MyWriter = struct {
my_data: u32,
interface: std.Io.Writer,
fn drain(io_w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize {
const self: *MyWriter = @alignCast(@fieldParentPtr("interface", io_w));
_ = self.my_data; // Can access parent struct fields
// Process buffered + incoming data, return bytes consumed.
// Every slice counts once, except the last: it repeats splat times.
io_w.end = 0;
var total: usize = 0;
for (data[0 .. data.len - 1]) |slice| total += slice.len;
total += data[data.len - 1].len * splat;
return total;
}
};
I’ve hit all of these at least once:
"{f}" for types with format methods, not "{}"std.io.getStdOut() is now std.fs.File.stdout()var w = impl.interface); always use pointers (&impl.interface). The vtable recovers the parent with @fieldParentPtr, and the copy breaks that. Standalone writers like Writer.fixed are plain values and copy fine. See the migration guide for details.