migration

0.16 migration notes

verified with zig-aarch64-macos-0.16.0-dev.3059+42e33db9d

high-impact changes

  • std.net removed — networking now via Io.net
  • std.crypto.random removed — split into io.random() (fast PRNG) and io.randomSecure() (CSPRNG, can block)
  • std.Thread.Pool removed — thread-per-connection (Thread.spawn + .detach())
  • std.Thread.Mutex removedIo.Mutex (requires io param for lock/unlock)
  • std.Thread.sleep removedio.sleep(.{ .nanoseconds = N }, .awake) catch {}
  • std.time.timestamp/milliTimestamp removedIo.Timestamp.now(io, .real)
  • posix.shutdown removed — use std.c.shutdown(fd, how)
  • posix.getenv removed — use std.c.getenv + std.mem.span() (no Io equivalent)
  • posix.getrandom removed — use io.random() or io.randomSecure()
  • posix.exit removed — use std.process.exit()
  • GeneralPurposeAllocator removedstd.heap.DebugAllocator(.{}).init (tests) or smp_allocator (prod)

rule of thumb: use Io-based APIs. only fall back to std.c.* when no Io equivalent exists (just getenv and shutdown so far).

cross-compilation: -Dtarget strips CPU features

-Dtarget=x86_64-linux-musl without -Dcpu defaults to generic x86_64 — no SSE, no AVX, nothing. std.simd.suggestVectorLength(f32) returns null. code using @Vector falls back to scalar.

# WRONG — scalar loops, 35ms per NER call
RUN zig build -Doptimize=ReleaseFast -Dtarget=x86_64-linux-musl

# RIGHT — AVX2 enabled, 1.6ms per NER call (21x faster)
RUN zig build -Doptimize=ReleaseFast -Dtarget=x86_64-linux-musl -Dcpu=x86_64_v3

without -Dtarget, zig detects the build host's CPU features (which is why local builds are fast). the moment you cross-compile, you must specify the CPU model.

common levels: x86_64 (SSE2), x86_64_v2 (SSE4.2), x86_64_v3 (AVX2), x86_64_v4 (AVX-512). all AMD EPYC (fly.io) support x86_64_v3.

these require significant refactoring as they move to the Io-based paradigm.

Alpine / musl deployment

with link_libc = true, zig defaults to glibc which uses statx — unavailable in musl (Alpine). binary crashes with Error relocating: statx: symbol not found. fix: build with explicit musl target:

zig build -Doptimize=ReleaseFast -Dtarget=x86_64-linux-musl

build.zig changes

linkLibC() removed. use module options instead:

// 0.15
const exe = b.addExecutable(.{...});
exe.linkLibC();

// 0.16
const exe = b.addExecutable(.{
    .name = "myapp",
    .root_module = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
        .link_libc = true,  // here
    }),
});

package management

0.16 calculates hashes differently. use zig fetch --save to update deps — it writes the URL and correct hash directly into build.zig.zon:

# add or update a dependency
zig fetch --save=websocket "https://github.com/user/repo/archive/abc1234.tar.gz"

# overwrites the existing entry if the name already exists

no need for placeholder hashes or manual hash computation.

local zig-pkg directory

fetched packages are now stored in a zig-pkg/ directory in the project root (next to build.zig). replaces the opaque global cache. benefits:

  • self-contained offline source tarballs
  • IDE autocomplete works on dependency source
  • zig-pkg/ should be gitignored

--fork flag for local overrides

zig build --fork=dep_name=/path/to/local/checkout temporarily overrides a dependency across the entire dependency tree using a local source checkout. useful for testing upstream fixes without modifying build.zig.zon.

compiler improvements

lazier type resolution

the compiler no longer analyzes fields of types that are never initialized (e.g. structs used purely as namespaces). eliminates "over-analysis" bugs, speeds up incremental compilation. dependency loop errors now provide detailed multi-line messages showing exactly which fields/types caused the cycle.

removed APIs

std.time.timestamp() / milliTimestamp()

use Io.Timestamp.now():

const Io = std.Io;

fn timestamp(io: Io) i64 {
    return @intCast(@divFloor(Io.Timestamp.now(io, .real).nanoseconds, std.time.ns_per_s));
}

fn milliTimestamp(io: Io) i64 {
    return @intCast(@divFloor(Io.Timestamp.now(io, .real).nanoseconds, std.time.ns_per_ms));
}

// if you don't have io in scope:
const io = std.Options.debug_io;
const now = milliTimestamp(io);

std.Thread.sleep()

use io.sleep():

// 0.15
std.Thread.sleep(3 * std.time.ns_per_s);

// 0.16
io.sleep(.{ .nanoseconds = 3 * std.time.ns_per_s }, .awake) catch {};
// .awake = monotonic clock (excludes suspend), .real = wall clock
// returns Cancelable!void — catch {} for fire-and-forget

std.Thread.Mutex

replaced by Io.Mutex (requires io parameter):

// 0.15
var mutex: std.Thread.Mutex = .{};
mutex.lock();
defer mutex.unlock();

// 0.16
var mutex: std.Io.Mutex = std.Io.Mutex.init;
mutex.lockUncancelable(io);
defer mutex.unlock(io);

std.posix.getenv()

use std.c.getenv() with std.mem.span():

// 0.15
const val = std.posix.getenv("FOO") orelse "default";

// 0.16 - returns [*:0]const u8, not slice
const val = if (std.c.getenv("FOO")) |p| std.mem.span(p) else "default";

@Type builtin

removed. use specific type builtins:

// 0.15
const Args = @Type(.{ .@"struct" = .{ .is_tuple = true, ... } });

// 0.16
const Args = @Tuple(&field_types);
// or @Struct(...) for non-tuples

posix.Sigaction signal handler type

handler parameter changed from c_int to posix.SIG (enum) on all platforms including macOS. builds with c_int on macOS but fails on Linux — cross-compilation trap.

// 0.15
fn handleSigterm(_: c_int) callconv(.c) void { ... }

// 0.16
fn handleSigterm(_: posix.SIG) callconv(.c) void { ... }

posix.shutdown

use libc directly:

// 0.15
posix.shutdown(fd, .recv) catch {};

// 0.16
_ = std.c.shutdown(fd, 0);  // SHUT_RD = 0, SHUT_WR = 1, SHUT_RDWR = 2

std.testing.expectEqual

argument order changed (minor but affects all tests):

// 0.15
try std.testing.expectEqual(@as(@TypeOf(actual), expected), actual);

// 0.16 - simpler, expected comes first naturally
try std.testing.expectEqual(expected, actual);

net.Stream.handle → net.Stream.socket.handle

// 0.15
const fd = stream.handle;

// 0.16
const fd = stream.socket.handle;

TLS client options

tls.Client.Options changed significantly:

// 0.15
.ca = .{ .bundle = bundle },
.realtime_now_seconds = seconds,

// 0.16 - bundle variant requires gpa, io, lock, and pointer
.ca = .{ .bundle = .{ .gpa = allocator, .io = io, .lock = rwlock_ptr, .bundle = bundle_ptr } },
.realtime_now = Io.Timestamp.now(io, .real),

Certificate.Bundle initialization

// 0.15 — fields had defaults
var b = Bundle{};

// 0.16 — no defaults on Linux, must initialize explicitly
var b: Bundle = .{ .map = .empty, .bytes = .empty };

Linux epoll_create1 return type

linux.epoll_create1 returns usize (raw syscall), but linux.epoll_ctl and linux.epoll_wait take epoll_fd: i32. cast at init:

const q_raw = linux.epoll_create1(0);
const q: i32 = std.math.cast(i32, q_raw) orelse return error.EpollError;

O_NONBLOCK cross-platform

never hardcode O_NONBLOCK — it differs per platform (macOS 0x0004, Linux 0o4000). use zig's typed packed struct:

const libc = std.c;
const O_NONBLOCK: c_int = @bitCast(libc.O{ .NONBLOCK = true });

// usage with fcntl
const flags = libc.fcntl(fd, libc.F.GETFL);
_ = libc.fcntl(fd, libc.F.SETFL, flags | O_NONBLOCK);

libc.O is a packed struct(u32) that varies by native_os@bitCast gives the correct integer for the target platform.

networking

std.net is gone. use Io.net:

const Io = std.Io;
const net = Io.net;

// connecting to a host
pub fn connect(io: Io, host: []const u8, port: u16) !net.Stream {
    const host_name = try net.HostName.init(host);
    return host_name.connect(io, port, .{});
}

note: Io.net.Stream no longer has direct read/writeAll methods. you need to create Stream.Reader or Stream.Writer wrappers:

// reading from stream requires a Reader wrapper
pub fn readFromStream(stream: net.Stream, io: Io, buffer: []u8) !usize {
    var reader = net.Stream.Reader.init(stream, io, buffer);
    // use reader.interface for reading
    return reader.interface.read(buffer);
}

net.Addressnet.IpAddress:

// 0.15
const addr = std.net.Address.parseIp("127.0.0.1", 8080);

// 0.16
var addr = try net.IpAddress.parse("::", port);  // "::" for all interfaces
var server = try net.IpAddress.listen(&addr, io, .{ .reuse_address = true });
defer server.deinit(io);

TCP server + HTTP (thread-per-connection)

// accept loop — replaces Thread.Pool
while (true) {
    const stream = server.accept(io) catch |err| { log.err(...); continue; };
    const t = std.Thread.spawn(.{}, handleConnection, .{ stream, io }) catch |err| {
        stream.close(io);
        continue;
    };
    t.detach();
}

// handler — Stream → Reader/Writer → http.Server
fn handleConnection(stream: net.Stream, io: Io) void {
    defer stream.close(io);
    var read_buf: [8192]u8 = undefined;
    var write_buf: [8192]u8 = undefined;
    var reader = net.Stream.Reader.init(stream, io, &read_buf);
    var writer = net.Stream.Writer.init(stream, io, &write_buf);
    var srv = std.http.Server.init(&reader.interface, &writer.interface);
    // ... handle requests ...
}

randomness

std.crypto.random removed. randomness split into two functions:

// 0.15
var bytes: [16]u8 = undefined;
std.crypto.random.bytes(&bytes);

// 0.16 — two options:
io.random(&bytes);        // fast PRNG, non-blocking, non-cancelable
try io.randomSecure(&bytes);  // CSPRNG, may block (OS entropy), returns Cancelable!void

use random() for non-security purposes (shuffling, jitter). use randomSecure() for cryptographic keys, nonces, tokens.

returns usize (length) instead of []const u8:

// 0.15
if (std.fs.readLinkAbsolute("/proc/self/exe", &buf)) |path| { ... }

// 0.16
if (std.Io.Dir.readLinkAbsolute(io, "/proc/self/exe", &buf)) |len| {
    const path = buf[0..len];
    ...
}

std.fs.cwd().makePath()

// 0.15
try std.fs.cwd().makePath(dir);

// 0.16
try std.Io.Dir.createDirPath(.cwd(), io, dir);

ArrayListUnmanaged init

// 0.15
var list: std.ArrayList(T) = .{};

// 0.16
var list: std.ArrayList(T) = .empty;

Server.close → Server.deinit

// 0.15
server.close();

// 0.16
server.deinit(io);

Stream.Reader → Io.Reader for http.Server

stream.reader() returns Stream.Reader, but http.Server.init wants *Io.Reader. access the .interface field:

var reader = stream.reader(io, &read_buf);
var writer = stream.writer(io, &write_buf);
var srv = std.http.Server.init(&reader.interface, &writer.interface);

File.read → File.readStreaming

File.read() removed. readStreaming takes []const []u8 (scatter/gather):

// 0.15
const n = f.read(&buf);

// 0.16
const n = f.readStreaming(io, &.{&buf});

file I/O

file ops now take io: Io parameter. get default io from std.Options.debug_io:

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

pub fn readFile(path: []const u8) ![]u8 {
    const io = std.Options.debug_io;

    const file = try Io.Dir.openFileAbsolute(io, path, .{});
    defer file.close(io);

    var buf: [4096]u8 = undefined;
    const n = try file.readPositional(io, &.{&buf}, 0);
    return buf[0..n];
}

pub fn writeFile(path: []const u8, data: []const u8) !void {
    const io = std.Options.debug_io;

    const file = try Io.Dir.createFileAbsolute(io, path, .{});
    defer file.close(io);

    try file.writeStreamingAll(io, data);
}