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) andio.randomSecure()(CSPRNG, can block) - std.Thread.Pool removed — thread-per-connection (
Thread.spawn+.detach()) - std.Thread.Mutex removed —
Io.Mutex(requiresioparam for lock/unlock) - std.Thread.sleep removed —
io.sleep(.{ .nanoseconds = N }, .awake) catch {} - std.time.timestamp/milliTimestamp removed —
Io.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()orio.randomSecure() - posix.exit removed — use
std.process.exit() - GeneralPurposeAllocator removed —
std.heap.DebugAllocator(.{}).init(tests) orsmp_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.Address → net.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.
std.fs.readLinkAbsolute
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);
}