alexrios
open main menu
Part of series:Writergate

Writergate part 1: the problem with generic I/O

/ 6 min read

In October 2023, Ian Johnson authored commit e392c1a0b3: “Add type-erased writer and GenericWriter.” It reached master in January 2024.

On August 29, 2025, Zig merged PR #25036: “std.Io: delete GenericWriter, AnyWriter, and null_writer.”

Nineteen months in the standard library. Deleted.

This isn’t a story of instability or poor planning. It’s a story of discovering that a reasonable-looking abstraction was fundamentally incompatible with larger goals. Understanding why Zig made this choice, the second major “-gate” after Allocgate in 0.9, illuminates a problem that haunts every typed language with generics.

Generic poisoning

Here’s a function that serializes data to any writer:

pub fn serialize(data: Data, writer: anytype) !void {
    try writer.writeAll(data.header);
    try writer.print("{d}", .{data.value});
}

Looks fine. But now consider what happens when another function calls it:

pub fn saveToFile(data: Data, file: std.fs.File) !void {
    const writer = file.writer();
    try serialize(data, writer);  // serialize is generic over writer type
}

And what happens when a struct holds a reference to such a function:

pub fn Processor(comptime WriterType: type) type {
    return struct {
        writer: WriterType,

        pub fn process(self: *@This(), data: Data) !void {
            try serialize(data, self.writer);
        }
    };
}

The generics propagate upward. Every function that touches I/O becomes parameterized by the writer type. Every struct that contains such a function becomes a generic type constructor.

Andrew Kelley’s Writergate PR describes the old interface as “poisoning structs that contain them and forcing all functions to be generic as well.” Generic poisoning is the right name for it. The type parameter spreads through your codebase like an infection.

The costs are real

The bill shows up in four places:

Compile times. Each unique writer type triggers a new monomorphization. If your library supports 5 different output targets, you compile the serialization code 5 times. Users who add custom writers add more instantiations.

Binary size. Each instantiation is a separate copy in the final binary. A logging library that writes to files, sockets, and buffers might triple its code size.

API inflexibility. You can’t store a writer in a struct field without making the struct generic. You can’t return a writer from a factory function without spelling out the whole generic type in the signature. Dynamic dispatch, choosing the writer at runtime, becomes awkward.

Testing friction. Want to mock a writer for testing? Your mock must match the exact generic signature. Want to inject a recording wrapper? More generics. I’ve seen teams just skip I/O testing because the setup wasn’t worth the hassle.

The Zig team knew this was a problem. Their solution: type erasure.

The type erasure attempt

In early 2024, Zig added AnyWriter and AnyReader. These wrapped concrete writers behind a vtable, eliminating the generic parameter:

// Before: generic poisoning
pub fn serialize(data: Data, writer: anytype) !void { ... }

// After: type-erased, no generics
pub fn serialize(data: Data, writer: std.io.AnyWriter) !void { ... }

Type erasure is the standard fix. Java interfaces, Go implicit interfaces, Rust’s dyn Trait: all variations on the same pattern. Hide the concrete type behind function pointers.

For a while, this seemed sufficient. It wasn’t.

Problem 1: performance in hot paths

Consider JSON serialization. You’re writing individual characters: quotes, commas, colons, escape sequences. Each write() is potentially just one or two bytes.

With AnyWriter, every write goes through a vtable. Function pointer lookup, indirect call, no inlining possible. For a tight loop writing thousands of small chunks, the overhead compounds.

The old design placed buffering below the vtable. You buffered the concrete writer first, then type-erased the result with .writer().any(). The vtable sat above the buffer, so even a write that would just land in the buffer crossed the vtable first:

User code
    ↓ write("hello")
AnyWriter (vtable dispatch)
    ↓ indirect call
BufferedWriter (has buffer, checks if full)
    ↓ if buffer full
Concrete writer (actual I/O)

Every small write paid the abstraction tax, even when the buffer wasn’t full.

Problem 2: imprecise errors

The type-erased interface used anyerror:

pub const AnyWriter = struct {
    writeFn: *const fn (context: *const anyopaque, bytes: []const u8) anyerror!usize,
    // ...
};

What errors can a write produce? Depends on the underlying writer:

  • File writer: error{NoSpaceLeft, AccessDenied, BrokenPipe, ...}
  • Socket writer: error{ConnectionResetByPeer, BrokenPipe, ...}
  • Allocating writer: error{OutOfMemory}; a fixed buffer: error{NoSpaceLeft}

With anyerror, callers couldn’t know. Error handling became defensive: catch everything, handle generically, lose information. The type system stopped helping.

Problem 3: concrete type lock-in

The irony: while AnyWriter freed consumers from generics, many stdlib APIs still required concrete types.

std.http expected std.net.Stream. Want to test HTTP code with a mock transport? Want to wrap the stream for logging? The API didn’t accommodate it.

Type erasure was opt-in, and not everywhere. The result was a mix: some APIs generic, some concrete, some type-erased. Inconsistency bred friction.

Problem 4: async incompatibility

This was the killer.

Zig’s async/await had been unavailable since version 0.11, pending a fundamental rework. When it came back, code would need a way to express that I/O operations might suspend.

The old AnyWriter had no vocabulary for this. Its write function returned anyerror!usize: either you got bytes written, or an error. No “pending” state. No continuation. No way to integrate with an event loop.

You could imagine extending AnyWriter with async variants, but that compounds the interface. Sync write, async write, maybe callback-based write: three ways to do the same thing, three function pointers, an explosion of complexity.

The Zig team faced a choice: keep patching AnyWriter with workarounds, or rethink the problem entirely.

The decision to delete

Looking at the timeline:

  • January 2024: AnyWriter lands, used in parts of stdlib
  • Early 2025: Work begins on the new Io design
  • July 2025: The Writergate PR (#24329) merges, introducing the new API
  • August 2025: GenericWriter, AnyWriter deleted

The deletion wasn’t impulsive. It followed months of designing the replacement, migrating stdlib code, and ensuring the new design actually solved the problems.

What emerged was radically different: buffers above the vtable, I/O as dependency injection, explicit async primitives. The interface looks unfamiliar because it’s solving different problems than “abstract over write destinations.”

If you’re wondering why your simple print statement now needs five lines of boilerplate, that’s the trade-off. You paid up front in 0.15 for capabilities that arrived in 0.16, when the async-capable Io shipped.

In part 2, I’ll cover the new architecture: three-level vtables, the drain() primitive, and how buffer placement enables both performance and async.


Next: Writergate part 2: the new architecture