Zig Standard Library, the I/O Interface, and Concurrency: Tying It All Together

This article is based on Zig 0.16.

After five installments covering syntax, error handling, memory management, compile-time computation, and the build system — we’ve arrived at the finale. Time to tie it all together.

Version 0.16 is a convergence point of two major changes: the Unmanaged migration of standard library containers, and the introduction of the revolutionary std.Io interface. These transformations deeply affect how Zig code is written. This article explores both, closes with three-language comparison cases, and provides a learning roadmap and resources.

Standard Library Containers: Unmanaged Migration

Zig 0.16’s standard library containers underwent a critical architectural change — the Unmanaged migration. The core idea is: instead of storing an allocator inside the struct, the allocator is passed explicitly on every method call that needs it. This aligns perfectly with Zig’s philosophy of “explicit over implicit.”

However, the migration progress in 0.16 is not uniform across all containers. ArrayList and HashMap have an important asymmetry in their migration status — you must keep this in mind when writing code.

ArrayList: Migration Complete

Since 0.16, std.ArrayList(T) defaults to the unmanaged version. The struct contains only items and capacity — no allocator field. Therefore methods like append, appendSlice, and deinit all accept an allocator parameter:

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const std = @import("std");

pub fn main() !void {
    const gpa = std.heap.page_allocator;

    // Since 0.16, std.ArrayList(T) is the unmanaged version: no allocator stored in the struct
    var list: std.ArrayList(i32) = .empty;          // Zero-capacity initialization
    defer list.deinit(gpa);                          // deinit accepts allocator

    // Optional pre-allocation: var list = try std.ArrayList(i32).initCapacity(gpa, 10);

    try list.append(gpa, 1);                         // append accepts allocator
    try list.append(gpa, 2);
    try list.appendSlice(gpa, &[_]i32{ 3, 4, 5 });   // appendSlice accepts allocator

    std.debug.print("Length: {d}\n", .{list.items.len}); // items is a public field
    for (list.items) |item| {
        std.debug.print("{d}\n", .{item});
    }

    if (list.pop()) |last| {                         // pop does NOT need allocator
        std.debug.print("Popped: {d}\n", .{last});
    }
}

The key API signature changes are immediately visible:

  • Initialize with .empty (zero capacity), instead of std.ArrayList(T).init(allocator)
  • append/appendSlice/deinit all accept an allocator parameter
  • pop/get/items — operations that don’t allocate — do not need an allocator

HashMap: Not Yet Migrated, Managed by Default

Unlike ArrayList, std.StringHashMap(V) has not been migrated — it defaults to the managed version (the struct stores the allocator internally). Therefore init(allocator), put, and deinit do not accept an allocator:

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const std = @import("std");

pub fn main() !void {
    const gpa = std.heap.page_allocator;

    // std.StringHashMap(V) is still the managed version: init stores the allocator
    var map = std.StringHashMap(i32).init(gpa);
    defer map.deinit();                              // deinit does NOT need allocator

    try map.put("apple", 1);                         // put does NOT need allocator
    try map.put("banana", 2);

    if (map.get("apple")) |v| {                      // get returns ?V
        std.debug.print("apple: {d}\n", .{v});
    }

    var it = map.iterator();
    while (it.next()) |entry| {
        std.debug.print("{s}: {d}\n", .{ entry.key_ptr.*, entry.value_ptr.* });
    }

    _ = map.remove("banana");                        // remove returns bool
    std.debug.print("Remaining: {d}\n", .{map.count()});
}

Optional: StringHashMapUnmanaged (Explicit Opt-in)

If you prefer the unmanaged version, explicitly use StringHashMapUnmanaged:

zig
1
2
3
4
// For an unmanaged map, explicitly use StringHashMapUnmanaged
var map: std.StringHashMapUnmanaged(i32) = .empty;
defer map.deinit(gpa);                  // deinit needs allocator now
try map.put(gpa, "apple", 1);           // put also needs allocator now

Comparison for Go / Rust Developers

  • Unlike Go, Zig has no built-in append() function — dynamic array operations use std.ArrayList’s append method.
  • Unlike Rust, Zig has no HashMap::new() — whether managed or unmanaged, Zig requires explicitly passing an allocator (either at init time or on each method call). Allocation behavior is always fully visible.

This asymmetry is the real state of Zig 0.16: ArrayList has fully completed the migration, while HashMap is still in transition. If you follow old documentation and write std.ArrayList(T).init(allocator), it will fail to compile — remember: use .empty for ArrayList, but .init(gpa) for StringHashMap.

The std.Io Interface: 0.16’s Breakthrough Feature

If the Unmanaged migration is an important architectural improvement, the std.Io interface is the most significant new feature in Zig 0.16. Its design philosophy mirrors the allocator pattern — by dependency injection, the same business logic code can run under different execution models.

Design Philosophy

The core idea of Zig 0.16 is: any operation that may block control flow or introduce non-determinism is managed by the I/O interface. This means file I/O, networking, process management, and even certain synchronization primitives must be performed through an Io instance.

The entry signature is straightforward:

zig
1
2
3
4
5
6
7
const std = @import("std");

pub fn main(init: std.process.Init) !void {
    const gpa = init.gpa;
    const io = init.io;
    // Business logic uses io for file/network/process I/O
}

Notice the new main function signature — it receives a std.process.Init parameter containing a global allocator (gpa) and I/O instance (io). Your program no longer makes system calls directly; all I/O operations go through the io instance.

I/O Backend Implementations

std.Io is an interface, not a concrete implementation. Different backends determine how I/O operations are actually executed:

BackendExecution ModelUnderlying ImplementationUse CaseStatus
Io.ThreadedSynchronous blocking, multi-threadedOS threadsCPU-bound, simple appsStable (default entry)
Io.EventedEvent-driven, asynchronous non-blockingUser-space stack switching / work stealing (M:N threads)I/O-bound, high-concurrency servicesExperimental
Io.UringLinux io_uringProof-of-concept (incomplete)
Io.KqueuemacOS kqueueProof-of-concept
Io.DispatchmacOS Grand Central DispatchProof-of-concept
Io.failingNo-op simulationTestingTesting-only

For most developers, Io.Threaded is the default — it is fully featured and well-tested. Io.Evented serves as an experimental backend pointing toward high-concurrency scenarios, based on user-space stack switching and work stealing (M:N threading, aka “green threads” / coroutines), though its API may still change.

High-Level Primitives

std.Io is not merely an I/O abstraction — it provides a rich set of high-level concurrency primitives:

  • Future — Task-level async, a handle for an asynchronous operation
  • Group — Manage a batch of independent tasks, with unified await or cancel
  • Queue(T) — Multi-producer, multi-consumer thread-safe queue
  • Select — Select over multiple I/O events
  • Batch — Batch I/O operations
  • Clock / Duration / Timestamp / Timeout — Time and timeout-related primitives

These primitives form the foundation of Zig 0.16’s asynchronous programming model. You don’t need to learn complex async/await keywords (removed as early as 0.13.0) — instead, you express concurrency by composing these primitives.

Concurrency: Thread.Pool Yields to Io.Group

std.Thread.Pool has been removed in 0.16, replaced by std.Io.Group. This is an important change — if you’ve read any Zig thread pool material before, be aware that it is now outdated.

Old Pattern (Removed)

zig
1
2
3
4
5
fn doAllTheWork(pool: *std.Thread.Pool) void {
    var wg: std.Thread.WaitGroup = .{};
    pool.spawnWg(wg, doSomeWork, .{ pool, &wg, first_work_item });
    wg.wait();
}

New Pattern (0.16+)

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
fn doAllTheWork(io: std.Io) void {
    var g: std.Io.Group = .init;
    errdefer g.cancel(io);
    g.async(io, doSomeWork, .{ io, &g, first_work_item });
    try g.await(io);
}

fn doSomeWork(io: std.Io, g: *std.Io.Group, foo: Foo) void {
    foo.doTheThing();
    for (foo.new_work_items) |new| {
        g.async(io, doSomeWork, .{ io, g, new });
    }
}

Key changes:

  1. No explicit Thread.Pool initialization neededIo.Group is a value type (= .init), ready to use out of the box
  2. errdefer guarantees cancellation — if a subsequent operation fails, all tasks can be cancelled uniformly
  3. The async method accepts an io instance — determines which backend executes the tasks (threaded or event-driven)
  4. The await method waits for all tasks — returns !void, enabling error propagation

Migration Notes

The official guidance indicates that if your code uses thread synchronization primitives, they should also be replaced when migrating to 0.16:

Old (Removed / Not Recommended)New (0.16+)
std.Thread.Poolstd.Io.Group
std.Thread.Mutexstd.Io.Mutex
std.Thread.Conditionstd.Io.Condition
std.Thread.ResetEventstd.Io.Event
std.Thread.WaitGroupIo.Group’s await

On “Function Coloring”

Zig’s I/O and concurrency model largely solves the classic “function coloring” problem — where async and sync functions cannot directly call each other:

  • No async keyword needed to mark functions — removed as early as 0.13.0
  • The same business logic runs in both sync and async modes — switch backends via the io parameter
  • Dependency injection instead of hardcoded execution model — the code itself doesn’t care whether it’s threaded or event-driven
  • ⚠️ But I/O operations still go through the io interface — some degree of “interface coloring” remains

Note for Go/Rust developers: Zig’s concurrency model differs from both Go and Rust. It doesn’t have Go’s built-in goroutine scheduler, nor Rust’s async/await Future system. Zig’s approach is to provide low-level primitives (threads, Io.Group, Io.Evented) and let libraries and applications choose their own concurrency strategy. The std.Io design goal is to decouple business logic from the execution model.

Multi-Language Comparison Cases

As we conclude the series, let’s review Zig’s core features through three-language comparisons. The following cases demonstrate dynamic arrays, generic functions, and structs with methods across Go, Rust, and Zig.

Case 3: Dynamic Arrays

Go:

go
1
2
3
4
5
6
7
8
9
func main() {
    var nums []int
    nums = append(nums, 1)
    nums = append(nums, 2, 3)

    for i, v := range nums {
        fmt.Printf("[%d] %d\n", i, v)
    }
}

Rust:

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fn main() {
    let mut nums = Vec::new();
    nums.push(1);
    nums.push(2);
    nums.push(3);

    for (i, v) in nums.iter().enumerate() {
        println!("[{}] {}", i, v);
    }
}

Zig (0.16+ Unmanaged pattern):

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
pub fn main(init: std.process.Init) !void {
    const gpa = init.gpa;
    var nums: std.ArrayList(i32) = .empty;
    defer nums.deinit(gpa);

    try nums.append(gpa, 1);
    try nums.append(gpa, 2);
    try nums.append(gpa, 3);

    for (nums.items, 0..) |v, i| {
        std.debug.print("[{}] {}\n", .{ i, v });
    }
}

Note: The Zig example has been updated for the 0.16+ Unmanaged pattern — uses .empty for initialization, and both append and deinit accept an allocator parameter.

Case 4: Generic Functions

Go (1.18+ generics):

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func sum[T int | float64](nums []T) T {
    var total T
    for _, v := range nums {
        total += v
    }
    return total
}

func main() {
    ints := []int{1, 2, 3}
    fmt.Println(sum(ints)) // 6
}

Rust (trait bounds):

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
fn sum<T: Add<Output = T> + Copy>(nums: &[T]) -> T {
    let mut total = nums[0];
    for &v in nums {
        total = total + v;
    }
    total
}

fn main() {
    let nums = vec![1, 2, 3];
    println!("{}", sum(&nums)); // 6
}

Zig (comptime generics):

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
fn sum(comptime T: type, nums: []const T) T {
    var total: T = 0;
    for (nums) |v| {
        total += v;
    }
    return total;
}

pub fn main() void {
    const nums = [_]i32{1, 2, 3};
    const result = sum(i32, &nums); // 6
    std.debug.print("{}\n", .{result});
}

Zig uses comptime T: type for generics, requiring neither Go’s type constraints nor Rust’s trait bounds — “as long as the type supports +, it works.” This is compile-time duck typing in action.

Case 5: Structs and Methods

Go:

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type Point struct {
    X, Y float64
}

func (p Point) Distance() float64 {
    return math.Sqrt(p.X*p.X + p.Y*p.Y)
}

func main() {
    p := Point{X: 3, Y: 4}
    fmt.Println(p.Distance()) // 5
}

Rust:

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
struct Point {
    x: f64,
    y: f64,
}

impl Point {
    fn distance(&self) -> f64 {
        (self.x * self.x + self.y * self.y).sqrt()
    }
}

fn main() {
    let p = Point { x: 3.0, y: 4.0 };
    println!("{}", p.distance()); // 5
}

Zig:

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const Point = struct {
    x: f64,
    y: f64,

    fn distance(self: *const Point) f64 {
        return @sqrt(self.x * self.x + self.y * self.y);
    }
};

pub fn main() void {
    const p = Point{ .x = 3.0, .y = 4.0 };
    std.debug.print("{}\n", .{p.distance()}); // 5
}

Zig’s structs define data and methods together (no Go receiver syntax or Rust impl block), with methods explicitly receiving the caller reference via a self: *const T parameter.

Learning Roadmap

If you’ve been with us from the first article, you’ve already completed Phase 1. Here are the complete four-phase recommendations:

Phase 1: Basics (1-2 weeks)

  • Variables, constants, basic data types
  • Control flow (if, switch, loops)
  • Function definition and calling
  • Structs and methods
  • Basic error handling (try/catch)

Phase 2: Core Concepts (2-3 weeks)

  • Allocator pattern and memory management
  • comptime compile-time computation
  • Generic programming (comptime T: type + anytype)
  • Common standard library data structures
  • defer/errdefer resource management

Phase 3: Advanced Features (3-4 weeks)

  • Pointers and slices
  • Compile-time reflection and metaprogramming
  • Concurrent programming (Io.Group, std.Thread)
  • std.Io interface (0.16+, watch for version changes)
  • C interop

Phase 4: Practical Projects (ongoing)

  • Command-line tool development
  • Network programming (consider libxev and other community libraries)
  • Systems-level programming
  • Contributing to open-source projects

Learning Resources

Official Resources

  • Zig Official Documentation: ziglang.org/documentation/ — the most authoritative reference
  • Zig Learn: ziglearn.org/ — community-maintained introductory tutorial
  • Zig Source Code: codeberg.org/ziglang/zig — the standard library is the best learning material
  • Zig Downloads: ziglang.org/download/

Chinese Resources

  • Zig Language Bible: course.ziglang.cc/ — Chinese introductory tutorial
  • Zig Chinese Community: ziggit.cn/ — Chinese discussion forum

Advanced Resources

  • Loris Cro’s Blog: kristoff.it/blog/ — blog of a Zig core developer
  • LWN.net: lwn.net/ — in-depth technical articles
  • Zig Show: YouTube channel with community interviews and technical talks

Final Words

Zig is a language evolving at a rapid pace. This six-part series spans from 0.11 to 0.16, witnessing the removal of async/await, the farewell to Thread.Pool, the thread-safety of ArenaAllocator, the retirement of GeneralPurposeAllocator, the Unmanaged migration of standard library containers, and the birth of the std.Io interface.

Behind all these changes runs a clear philosophical thread: expose mechanisms, don’t impose policies. Allocators are passed-in parameters, not global settings. I/O is an injectable choice through an interface, not a built-in semantic. Compile-time is a switch you can toggle, not a black box.

If you’ve read this far, may these six articles be the beginning of your Zig journey. Source code is the most honest documentation — read the standard library’s source, write something real. Ask questions on ziggit.cn, submit PRs on Codeberg, participate in Zig Show discussions. This language is still young, and its story is far from finished.

And you — you’re the next person to write that story.