giluis

July 27, 2026, 10:32pm 1

In his Don’t forget to flush talk, Andrew mentions one of the advantages of having an io instance passed as an argument is that function purity becomes apparent. I love that.
However, sometimes standard library types store their own io instance, making this function purity unapparent at first glance.

take this simple http server program:

const std = @import("std");
const print = std.debug.print;
const Io = std.Io;

pub fn main(init: std.process.Init) !void {
    const io = init.io;
    const address = try Io.net.IpAddress.parse("0.0.0.0", 8000);
    var server = try address.listen(io, .{});
    defer server.deinit(io);
    const stream = try server.accept(io);
    defer stream.close(io);

    var read_buffer: [1024] u8 = @splat(0);
    var write_buffer: [1024] u8 = @splat(0);
    var reader = stream.reader(io, &read_buffer);
    var writer = stream.writer(io, &write_buffer);

    var http_server = std.http.Server.init(&reader.interface, &writer.interface); 
    // server will wait for requests in the line below 
    var request =  try http_server.receiveHead();
    
    var body_buffer: [1024]u8 = @splat(0);
    const bodyReader = request.readerExpectNone(&body_buffer);
    var arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator);
    const alloc = arena.allocator();
    
    const body = try bodyReader.readAlloc(alloc, @intCast(request.head.content_length orelse 0));
    std.debug.print("{any}", .{body});
    const html = try readHtml(alloc, io);
    try request.respond(html, .{.status = .ok});
}

fn readHtml(alloc: std.mem.Allocator, io: Io) ![] u8 {
    return try std.Io.Dir.cwd().readFileAlloc(io, "./index.html", alloc, Io.Limit.unlimited);
}

If you look into std.http.Server.receiveHead you’ll see that defines a std.http.Server.Reader, which contains a std.Io.Reader, which in turn contains an Io instance.
Why does/should std.Io.Reader contain an Io instance within it, instead of requiring it be passed in all its functions?
I guess it’s a little less verbose, but we do lose this ability to tell at a glance which functions may do Io (and possibly block).

More generally, what’s the rule of thumb to pass Io instances (or for that matter, std.mem.Allocator instances) to every function call, vs having a struct store it and pass it “implicitly”?

Let me know if any clarification is needed

IceGuye July 27, 2026, 11:07pm 2

When Zig library developers say you need to pass an Io or Allocator or whatever, that usually means that the library itself will not hiddenly create its own instance. It doesn’t mean that every single public fn will require an Io and Allocator.

Whenever you initiate the http.Server, you will need a stream reader and stream writer. So you have to initiate them firstly… When you initiate the stream reader and writer, you will need to pass the Io. So this is your Io, not the http.Server Io… So you put the defer close() on the next lines of the stream reader and writer, not the http.Server.init(). The http.Server doesn’t hold the Io, not even the reader, as the reader just an interface.

Another example is JSON and anything their name ended as a “Managed”, such as ArrayListManaged. Those will hold the Allocator and/or Io, but they will not create it themselves.

This is very important because they allow the program to group operations together. They also tell you that this instance will hold some data for a longer lifetime so the data is reusable in the future. This will also make the ArrayList.deinit() function possible, instead of abusing the defer allocator.free in half of your codes.

In my understanding, for Allocator, if you may only use a few instances, but they need to store a lot of complicated data in heap, and it has a lot of functions will do some loops to manipulate those complicated data, they would probably have a managed version. They may even create their own ArenaAllocator and store it, such as the std.json. When you pass an Allocator into their init(), they essentially just use your Allocator as the child_allocator. The reason is that, they need to use the allocator to fine tune their allocation for their complicated loops and calculations, because some Allocator can retain the capacity to avoid relloc too often… I don’t know much about Io yet, so I am not sure when storing the Io is better. My personal rule of thumb is that, if I think storing the Allocator is better, I will also store the Io, so the init() function can have Io and Allocator together.

Of course, on the other hand, if the instance only holds some simple data, and you may have a lot of instances, it’s good to have unmanaged instance as well, but I think it’s case by case now.

If an unmanaged instance is very complicated and has a lot of functions required Io and Allocator, it may create a lot of confusions to the users. The user may not know when to free them. That can cause UAF, or double free, or leaking. It needs very good transparency and documentation. The reason is when users use an instance and see a function required Io and Allocator, they have to think about when to free them. When I created a library for my team using, I generally follow a rule: “whenever you see an Allocator and/or Io pass in, you need to defer free them or deinit() next line”. So one pass, one free… A Managed instance can create the scene of “one pass one deinit”.

In sum, when Zig says reusable codes (lib) should accept Io and Allocator pass, it means it allows the user to create their Io and Allocator, and pass through… It doesn’t mean how many times and how many functions you can pass through. As long as it is your own Io and own Allocator, that’s the Zig way.

alanza July 27, 2026, 11:15pm 3

I think that storing these things is mostly an antipattern, and that part of what you’re seeing is that it is a seductive antipattern. Because std.Io is so new, we have yet to discover what an “unmanaged” version of some of these structs might properly look like.

std.Io.Reader does not contain an Io instance or require one actually (and std.http.Server.receiveHead does not interact with an Io instance directly, the reason receiveHead blocks is because you are passing in std.Io.net.Stream.Reader which does use io into std.http.Server.init). Readers/Writers are an abstraction over streams of bytes which doesn’t inherently require io (e.g. Reader.fixed) but some implementations of the Reader interface do require io like std.Io.File.Reader. Implementations of the Reader interface are able to use @fieldParentPtr to get an io instance from their parent struct if they need one. You can’t do something like options: struct {io: ?std.Io = null} on every Reader function that calls into the vtable because users of the Reader interface don’t know whether to pass in io or not or whether the reader requires some bespoke io. Also that’s a lot of copying the io interface which I would assume to be slower than @fieldParentPtr (?).

My rule of thumb for whether to have io/gpa in a struct is how many instances of the struct there are and how much the struct is using io/gpa. If theres 1 instance of the struct and it uses io/gpa in more than a few functions then I store it in the struct. If theres more than one then I feel like I would be wasting space storing io (@sizeOf(usize) * 2) or gpa (also @sizeOf(usize) * 2) (there’s 2 ptrs in each struct).

For an interface, if the interface requires io/gpa then you should pass it in because there should be multiple instances of an interface and if it doesn’t require io/gpa then let the implementations do @fieldParentPtr or whatever other retrieval method to get the stuff they need.