You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This file is a turn-key quickstart for pointing an AI (or yourself) at a Zig codebase that needs a 0.15.x → 0.16.0 migration. It's small on purpose. The actual reference material lives in ZIG-0.16.0.md (1,800+ lines of changelog, patterns, decoder tables, and workflow playbook distilled from a real end-to-end port).
What this kit is and isn't
It IS: A field-tested migration playbook that took a ~7,300-line Zig 0.15.2 parser generator to 0.16.0 in one session. Tests passed 40/40 afterwards. Performance went up (187s → 1.73s test runtime). It captures both the API changes and the surprises that aren't obvious from release notes alone — most importantly, std.heap.DebugAllocator's up-to-1400× slowdown on allocator-heavy workloads in Debug builds.
It ISN'T: A substitute for running the compiler. Every migration surfaces one or two project-specific surprises that no generic doc can predict. Use this kit to go fast through the known 80%, and plan for compile-fix-compile cycles on the last 20%.
Required inputs (what you must have)
The code to migrate. Obviously.
Zig 0.16.0 installed locally. The AI will verify API shapes against the installed stdlib source (much more reliable than trusting any prose doc).
Check with zig version — should print 0.16.0.
Install via your package manager (brew install zig on macOS, etc.).
A shell the AI can run (zig build, zig build test, rg, sed).
This file (ZIG-0.16.0-QUICKSTART.md) and its companion (ZIG-0.16.0.md).
Optional inputs (helpful but not required)
ZIG-0.15.2.md — only if your code is pre-0.15 (e.g., still uses usingnamespace, async/await keywords, old format string {} without {f}/{any}). If your code already compiled under 0.15.x, you don't need this.
A peer AI for review rounds — the nexus migration benefited materially from pre-execution critique and post-execution review via the user-ai MCP's discuss tool. Not required; scales the quality bar.
Copy-paste bootstrap prompt for a fresh AI chat
Paste this as your first message in a new chat. Replace the <…> fields.
I need to migrate a Zig codebase from 0.15.x to 0.16.0.
Reference files (both attached/available in this workspace):
- ZIG-0.16.0-QUICKSTART.md (start here; this is the protocol)
- ZIG-0.16.0.md (full changelog + decoder + playbook)
Codebase:
- Path: <absolute path to the project root>
- Primary source files: <list the .zig files or "the whole src/ tree">
- Has build.zig: <yes/no>
- Has a test harness: <yes/no + path to test runner if any>
- Uses generated code: <yes/no — if yes, describe briefly>
Zig 0.16.0 is installed locally (verified with `zig version`).
Please follow the "Migration Workflow Tactics" section at the end of
ZIG-0.16.0.md. Specifically:
1. Start with Phase 0 (empirical baseline — `zig build`, capture errors,
do not edit code yet).
2. Migrate one API family at a time, compiling between each step.
3. Time a representative real workload after the build goes green
(specifically watch for DebugAllocator slowdown — see the ⚠️
section under "Juicy Main").
4. Before declaring done, run the grep safety-net sweep from the
Workflow Tactics section.
Red flags specific to 0.16 I want you to check for:
- `= .{}` initializers on `ArrayListUnmanaged` / hashmaps (gone;
use `.empty`).
- `std.mem.trimLeft` / `trimRight` (renamed to trimStart/trimEnd).
- `std.fs.*` (gone; use `std.Io.Dir`/`std.Io.File` with a threaded `io`).
- `std.process.argsAlloc` (gone; use `init.minimal.args.toSlice`).
- `ArrayList(u8).writer(allocator)` code-gen pattern (gone; use
`std.Io.Writer.Allocating`).
- `init.gpa` in a short-lived CLI (consider `init.arena.allocator()`).
When you hit an API shape you're unsure about, read the installed
stdlib (`zig env` → std_dir → grep the relevant file) instead of
guessing. Don't trust my doc's API spellings blindly; they are a
starting point, not ground truth.
Go.
Protocol the AI should follow (mirror of Workflow Tactics)
Phase 0 — Empirical baseline.zig build with no code changes. Capture the first error. Do not edit yet.
Phase 1 — One API family at a time. In this order: main() signature → file I/O → Writer/Reader pattern → misc renames. zig build between each.
Phase 2 — Verify API shapes against stdlib. Before mass-editing, grep the installed stdlib for exact signatures. 5 minutes saved ~10 compile-fix cycles on the nexus port.
Phase 3 — Regenerate (if applicable). For codebases with generated files (parser output, protobuf, etc.): fix the generator's emit templates to produce 0.16-compatible output, then regenerate. Diff against git — expect only the migrated patterns.
Phase 4 — Test with a real workload.zig build test plus any project-specific tests. Time a large input. If you see 10×+ slowdown vs 0.15, you've likely hit the DebugAllocator trap — see the ⚠️ section.
Final sweep — grep safety nets. Run the grep commands from Workflow Tactics. Zero matches on all = high confidence migration is complete.
Red flags during execution (probe these immediately if you see symptoms)
Symptom
Likely cause
Where to look
missing struct field: items on ArrayListUnmanaged(T)
Lost field defaults
"Common Bad Assumptions #15" in ZIG-0.16.0.md
tried to invoke non-function 'writer' on Allocating
It's a field, not a method
"Common Bad Assumptions #13"
expected type 'std.Io.Limit' with integer
.limited(N) needed
"Common Bad Assumptions #14"
root source file struct 'mem' has no member 'trimLeft'
Renamed 0.16
"Std lib trim rename" subsection
zig build was fast on 0.15 but now takes 3+ minutes
DebugAllocator perf trap
⚠️ section under Juicy Main
400+ stderr lines on every ./my-program run
init.gpa leak-checking
Same ⚠️ section; consider arena
Generated file has old .{} patterns but source doesn't
Fix generator's emit templates, then regenerate
"Handling generated/vendored files" in Workflow Tactics
Signs of success
zig build → green, no output.
zig build test (or equivalent) → all pass.
Timed with a representative real workload — no order-of-magnitude slowdown vs 0.15.
Grep safety-nets from Workflow Tactics return zero matches.
No stderr spam on a clean ./my-program run.
git diff shows only the migrations you expected; nothing unexplained.
If all six are true, the migration is done.
Honesty disclaimer
This kit was written from exactly one real migration. It worked for that project. It will probably work for yours with minor adaptations. But every codebase has its own quirks, and 0.16 made enough changes that something novel will almost certainly surface.
When you hit something this kit doesn't cover: log it, fix it, and if you feel generous, open a PR against this file (or its parent ZIG-0.16.0.md) to help the next person.
This document provides a comprehensive overview of the changes and new features in Zig 0.16.0 (released April 16, 2026). If your knowledge of Zig stops at 0.15.x (or earlier), this is the fastest way to get up to speed on the latest language, standard library, build system, compiler, linker, and toolchain changes.
Zig 0.16.0 represents 8 months of work, 244 contributors, and 1183 commits. The headline feature is "I/O as an Interface" — a massive, pervasive reworking comparable to 0.15.1's "Writergate" but arguably larger in surface area. Alongside it, there are substantial language changes, a new "Juicy Main" entry point, the removal of @Type in favor of focused builtins, @cImport migration to the build system, a new ELF linker, a Smith-based fuzzer, and much more.
Starting with Zig 0.16.0, all input and output functionality requires being passed an Io instance. Generally, anything that potentially blocks control flow or introduces nondeterminism is now owned by the I/O interface.
Thread-based; supports cancelation, concurrency. Default from Juicy Main.
Io.Evented
Experimental, WIP
M:N / green threads / stackful coroutines. Informs API evolution.
Io.Uring
Proof-of-concept
Linux io_uring backend; lacks networking, error handling, etc.
Io.Kqueue
Proof-of-concept
Just enough to validate design.
Io.Dispatch
Proof-of-concept
macOS Grand Central Dispatch.
Io.failing
Utility
Simulates a system that supports no I/O operations — every I/O call returns an error. Useful for unit-testing code paths that must gracefully refuse I/O.
If you're upgrading from Zig 0.15.x, expect to touch almost every file that does any I/O or uses @Type. Here's the top-level checklist:
Expect many std APIs to require an Io handle. Propagate one through any call path that does I/O, concurrency, sync, time, or entropy. (You can still opt out in leaf code by constructing a local Io.Threaded.)
Consider "Juicy Main" — pub fn main() !void still compiles; adopting pub fn main(init: std.process.Init) !void (or Init.Minimal) is optional but recommended, since it gives you a pre-initialized io, gpa, arena, environ_map, preopens, and argv.
Replace @Type(...) with one of @Int, @Struct, @Union, @Enum, @Pointer, @Fn, @Tuple, @EnumLiteral. @Type and reifying error sets are gone.
@cImport is deprecated — migrate to b.addTranslateC(...) in build.zig.
std.fs.* → std.Io.Dir / std.Io.File, with an Io parameter added to most calls.
Use &@splat(.{}) to pass "default" attributes for every field/param.
@Struct/@Union/@Fn/@Enum use a "struct of arrays" layout — names, types, and attrs are separate arrays.
There is no @Float, @Array, @Optional, @ErrorUnion, @Opaque, @ErrorSet — use native syntax (f32, [N]T, ?T, E!T, opaque {}) or std.meta.Float where needed.
Reifying error sets is no longer possible. Declare them explicitly via error{ ... }.
Reifying tuple types with comptime fields is also no longer possible.
Packed unions can now declare explicit backing ints: packed union(u16) { ... }.
Fields of packed struct / packed union can no longer be pointers. Note: this restriction applies only inside packed types. Pointers are still fine in normal structs, extern struct/extern union, tagged unions, arrays, slices, optionals, etc. For tagged-pointer / NaN-boxing patterns, store a usize field and convert at use sites with @ptrFromInt / @intFromPtr. Rationale: non-byte-aligned pointers can't be represented in most binary formats, and some targets have fat pointers (extra metadata bits) that can't meaningfully be packed into an integer.
Enums with inferred tag types and packed types with inferred backing types are no longer valid extern types. Always spell out the tag/backing int in extern contexts.
5. Small Integers Coerce to Floats
If every value of an integer type fits losslessly in a float, the coercion is implicit (no @floatFromInt):
varfoo_int: u24=123;
varfoo_float: f32=foo_int; // ok — u24 fits in f32 significandvarbar_int: u25=123;
varbar_float: f32=@floatFromInt(bar_int); // still required
6. Float → Int via @floor/@ceil/@round/@trunc
constactual: u8=@round(12.5); // → 13
@intFromFloat is now deprecated (it's equivalent to @trunc + assignment).
7. Unary Float Builtins Forward Result Type
Builtins like @sqrt, @sin, @cos, @exp, @log, @floor, etc. now forward the result type, so this works:
constx: f64=@sqrt(@floatFromInt(N));
8. Runtime Vector Indexing Forbidden
for (0..vector_len) |i|_=vector[i]; // ❌
Instead, coerce to an array:
constvt=@typeInfo(@TypeOf(vector)).vector;
constarray: [vt.len]vt.child=vector;
for (&array) |elem|_=elem;
Also, vectors and arrays no longer support in-memory coercion (e.g. @ptrCast between *[4]i32 and *@Vector(4, i32) is gone). Use coercion. If you have anyerror![4]i32, unwrap before coercing.
9. No Returning Pointers to Trivially-Local Addresses
fnfoo() *i32 {
varx: i32=1234;
return&x; // error: returning address of expired local variable 'x'
}
Packed unions are now directly comparable by their backing integer without wrapping in a packed struct.
11. Lazy Field Analysis
struct, union, enum, and opaque types are now only resolved when their size or a field type is actually needed. Files (which are structs) and types used purely as namespaces no longer trigger field analysis. Non-dereferenced *T no longer requires T to be resolved.
12. Pointers to Comptime-Only Types Are No Longer Comptime-Only
*comptime_int, []comptime_int, and similar can exist at runtime (they just can't be dereferenced at runtime, except for fields that have runtime types).
One practical consequence: you can pass a []const std.builtin.Type.StructField to a runtime function and read the .name field at runtime.
13. *T Now Distinct from *align(1) T Where Natural Align ≠ 1
They still coerce to each other freely — but they print and compare as different types. Think of it like u32 vs c_uint.
14. Simplified Dependency Loop Rules
New dependency loops are possible, but the error messages are now far clearer, with a numbered chain of "uses X here" notes. Zig 0.16 significantly reworks internal type resolution (see Compiler → Reworked Type Resolution).
15. Zero-bit Tuple Fields No Longer Implicitly comptime
constS=struct { void };
@typeInfo(S).@"struct".fields[0].is_comptime// 0.15: true// 0.16: false (but the value is still comptime-known in practice)
Types struct { void } and struct { comptime void = {} } are no longer equal.
Standard Library Changes
Added
Io.Dir.renamePreserve — rename without clobbering destination.
std.Io.Dir.rename now returns error.DirNotEmpty rather than error.PathAlreadyExists.
readFileAlloc and similar limited-read APIs now signal "hit the limit" with error.StreamTooLong (not error.FileTooBig). The error type is part of ReadFileAllocError and the new error semantics unify "file exceeded limit" and "stream exceeded limit" into one error name.
Many 0.16 APIs that used to take a bare usize max-size now take an Io.Limit instead (e.g., readFileAlloc, streamDelimiterLimit, sendFileAll, etc.). Io.Limit is an enum(usize) with open discriminants:
.limited(1<<20) // at most 1 MiB (enum-literal method-call syntax).unlimited// no cap.nothing// zero-byte cap (useful for "don't read anything")
Because the method is named limited, .limited(N) works wherever Io.Limit is the inferred target type. If the target type is ambiguous, write std.Io.Limit.limited(N) explicitly.
std.Io.Writer.Allocating — the ArrayList(u8).writer() replacement
This is one of the most important 0.16 APIs in practice. If you had any 0.15-era code using the ArrayList(u8).writer(allocator) idiom for building strings, code-generating, or accumulating formatted output, this is your migration target.
// ❌ 0.15 stylevarout: std.ArrayListUnmanaged(u8) = .{};
deferout.deinit(allocator);
constw=out.writer(allocator);
tryw.print("hello {s}\n", .{name});
tryw.writeAll("world");
constbytes=tryout.toOwnedSlice(allocator);
// ✅ 0.16 stylevarout: std.Io.Writer.Allocating= .init(allocator);
// Note: no `defer out.deinit()` if you return ownership via toOwnedSlice.tryout.writer.print("hello {s}\n", .{name});
tryout.writer.writeAll("world");
constbytes=tryout.toOwnedSlice();
Passing the writer to helpers that expect *std.Io.Writer:
fnemitHeader(w: *std.Io.Writer, name: []constu8) !void {
tryw.print("// {s}\n", .{name});
}
varout: std.Io.Writer.Allocating= .init(allocator);
tryemitHeader(&out.writer, "mymodule"); // take address of the field// or for `writer: anytype` helpers, either `&out.writer` or `out.writer` works// depending on what the body does with it.
Key facts to remember:
writer is a field, not a method. Don't write out.writer() — that fails to compile. Write out.writer.print(...) or &out.writer when you need a pointer.
.toOwnedSlice() transfers ownership — the Allocating resets to empty and no deinit is needed afterward.
.written() returns a borrowed view into the current buffer. The returned slice invalidates on the next write — do not hold it across further writer.print/writeAll calls.
.fromArrayList(allocator, *ArrayList(u8)) wraps an existing ArrayList so you can migrate incrementally without rebuilding accumulated state.
Under the hood, Allocating.drain / sendFile are the vtable hooks; you rarely touch them directly.
Mid-build inspection (use with care):
varout: std.Io.Writer.Allocating= .init(allocator);
tryout.writer.writeAll("abcdef");
constview=out.written(); // "abcdef", borrowedstd.debug.assert(view.len==6);
tryout.writer.writeAll("ghi");
// view is NOW POTENTIALLY INVALID — do not use `view` past this line.// Call out.written() again to get a fresh slice.
Use case: streaming into an existing ArrayList and back:
varlist: std.ArrayList(u8) =.empty;
deferlist.deinit(allocator);
varout=std.Io.Writer.Allocating.fromArrayList(allocator, &list);
tryout.writer.print("appended: {d}\n", .{42});
list=out.toArrayList(); // resets Allocating, gives back the ArrayList// or just toOwnedSlice() if you want the bytes detached from the list.
heap.ArenaAllocator Now Threadsafe & Lock-Free
ArenaAllocator can now back an Io instance (because it no longer depends on mutexes). Single-thread perf is comparable; multi-thread ~up to 7 threads shows slight speedup vs previous "wrap in ThreadSafe" pattern. (DebugAllocator is planned to follow.)
Other Standard Library Changes
math.sign returns the smallest integer type that fits the possible outputs.
ArrayList(Unmanaged) lost its field defaults — use .empty
This is one of the highest-impact-per-character 0.16 changes, and one I originally missed documenting. std.ArrayListUnmanaged(T) and std.ArrayList(T) no longer have default values for their items and capacity fields. That means every 0.15-era pattern like this stops compiling:
// ❌ 0.15 style — no longer works in 0.16varlist: std.ArrayListUnmanaged(T) = .{};
varlist=std.ArrayListUnmanaged(T){};
constMyStruct=struct {
items: std.ArrayListUnmanaged(T) = .{}, // field default
};
The 0.16 replacement is the .empty decl literal (consistent with BitSet, EnumSet, PriorityQueue, etc.):
@splat(.{}) filling an array of ArrayListUnmanaged → @splat(.empty).
User-defined wrapper structs that hold an ArrayListUnmanaged field. If the wrapper previously worked with Wrapper = .{} (because all its fields had defaults), you need to either add pub const empty: Wrapper = .{} on the wrapper and switch callers to .empty, or update callers to write out the fields explicitly.
Migration tactic:sed -i 's/= \.{}/= .empty/g' yourfile.zig is usually safe because = .{} almost exclusively refers to container initialization. The few places it's not (e.g., fn foo() .{} {} return types) will compile-error loudly and can be hand-fixed.
std.mem.trimLeft / trimRight renamed to trimStart / trimEnd
Mechanical sed: sed -i -e 's/std\.mem\.trimLeft/std.mem.trimStart/g' -e 's/std\.mem\.trimRight/std.mem.trimEnd/g' yourfile.zig.
I/O as an Interface (Deep Dive)
Futures
Task-level abstraction based on functions.
io.async(func, .{args...}) — creates Future(T). Always infallible; may execute synchronously.
io.concurrent(func, .{args...}) — like async, but must be concurrent. Can fail with error.ConcurrencyUnavailable.
future.await(io) — block until done; returns the function's return value.
future.cancel(io) — request cancelation and await. Idempotent.
⚠️API-shape note. The free-function spawn (io.async(func, .{args...})) passes the target function's args as the tuple — io itself is not in the tuple (it's the receiver). For Io.Group, by contrast, io is the first argument: group.async(io, func, .{args...}). The two shapes are intentional; don't mix them up.
🗒️ Spelling note: the Zig team explicitly spells it "cancelation" (single l) — adopt this in your APIs, docs, and tests to match the ecosystem.
Cancelation requests may or may not be acknowledged.
If acknowledged, I/O functions return error.Canceled.
io.checkCancel — manual cancelation point (rarely needed).
io.recancel() — re-arm after handling error.Canceled.
io.swapCancelProtection() — declare that error.Canceled is unreachable in a block.
Handling rules:
Propagate error.Canceled, or
io.recancel() and don't propagate, or
Use io.swapCancelProtection() when it's definitively unreachable.
Only the requester can soundly ignore error.Canceled.
Batch
A low-level concurrency primitive that works at an operation layer rather than the function layer. Eligible ops today:
FileReadStreaming
FileWriteStreaming
DeviceIoControl
NetReceive
Batch is efficient and portable but less ergonomic than Future. Use Future to prototype; drop to Batch later if task overhead matters. operateTimeout will eventually work on anything operation-backed.
Select, Queue, Clock/Duration/Timestamp/Timeout
Select — wait until one (or more) of a set of tasks finishes; task-level analogue of Batch.
varargs=init.args.iterate();
while (args.next()) |arg|...
Juicy:
constargs=tryinit.minimal.args.toSlice(init.arena.allocator());
// Return type: ![]const [:0]const u8 — slice of null-terminated slices.// args[0] is the executable path; args[1..] are user arguments.// Safe to pass elements to std.mem.eql(u8, arg, "--flag"), std.debug.print("{s}", .{arg}), etc.
Note: toSlice is fallible (allocates into the arena). Prefer init.args.iterate() on the Minimal path if you want a zero-allocation iterator.
⚠️init.gpa is DebugAllocator in Debug — TWO big surprises
This is one of the most impactful 0.16 behavioral changes for programs migrating from std.heap.page_allocator. There are two independent surprises:
Surprise 1: Latent leaks now dump to stderr
init.gpa in Debug is a std.heap.DebugAllocator that performs leak detection at process exit. Exit code stays 0, but every un-freed allocation prints a stack trace. These are almost always pre-existing bugs that page_allocator silently masked. Common culprits:
const owned = try allocator.dupe(u8, s); stored in a hashmap and never freed.
try xs.toOwnedSlice(allocator) where the source ArrayListUnmanaged wasn't deinit-ed.
Arena-style ad-hoc allocators layered over page_allocator that relied on process-exit cleanup.
Surprise 2 (much bigger): catastrophic slowdown on allocator-heavy workloads
DebugAllocator's per-allocation bookkeeping is O(n) in the live-allocation count. When that count grows — because your program has long-lived allocations, or (worse) leaks that don't free until exit — every subsequent allocation does a lookup against a larger tracking set. In practice this translates to up to 1400× slowdown for programs that do many small allocations and retain most of them.
Concrete data from a real migration (the nexus parser generator, 0.15.2 → 0.16.0):
Workload
page_allocator (0.15)
init.gpa (0.16 Debug)
init.arena (0.16 Debug)
slowdown vs arena
Small grammar (basic)
~instant
0.55s
0.34s
1.6×
Medium grammar (features)
~instant
0.67s
0.014s
48×
MUMPS grammar
~instant
23s
0.28s
82×
slash grammar
~instant
8s
0.05s
160×
zag grammar
~instant
27s
0.20s
1,421×
Full test suite
~5s
187s
1.73s
108×
A ReleaseSafe build of the same code: MUMPS generation drops from 23s (Debug+init.gpa) to 0.033s (ReleaseSafe+smp_allocator) — a 700× swing purely from the allocator change. So the slowdown is not "generally Zig 0.16" — it's specifically DebugAllocator in Debug.
Three handling strategies
init.arena.allocator() at the top of main(). Best choice for short-lived CLIs: read input, compute, emit output, exit. Nexus, code generators, grammar compilers, most build-time tools fit this shape. Arena has zero leak-tracking overhead and individual .free() calls become harmless no-ops. Silences both surprises.
pubfnmain(init: std.process.Init) !void {
constallocator=init.arena.allocator();
constio=init.io;
// ... rest of main ...
}
Keep init.gpa; fix every leak. Correct answer for long-lived programs (servers, LSPs, interactive tools, libraries). Large scope but delivers the right signal: leaks now have behavioral consequences.
init.gpa with tests, init.arena in production. Hybrid: keep the DebugAllocator signal for leak-hunting sessions and CI auditing, but default to arena for speed. Simplest implementation is a CLI flag or env var that swaps the allocator at startup.
How to decide which strategy applies
If your program is…
Use
A one-shot CLI that reads input, computes, writes output, exits
arena
A long-running server, LSP, daemon, or REPL
fix leaks
A library consumed by other code
fix leaks (callers don't want your leaks)
A code generator / compiler with allocator-heavy codegen
arena
A build-time tool (e.g. build.zig scripts)
arena
The migration-authoring heuristic
Post-mortem observation from a real migration: allocator-behavior changes are invisible from release notes alone. The 0.16 release notes tell you std.heap.page_allocator is no longer the recommended default and that init.gpa is a DebugAllocator. What they cannot tell you is how that interacts with your program's specific allocation pattern — and for allocation-heavy programs, the interaction can be catastrophic.
Lesson: when migrating, time a representative real workload in Debug mode before calling the migration "done." Cheap to do (time ./your-tool <real-input>), cheap to spot (any 10×+ regression vs 0.15 is almost certainly this).
Corollary: OOM paths may wake up
If your existing code relied on page_allocator's "never fails" behavior, init.gpa may also surface OOM error paths that were previously dead code. That's generally good, but it's a real behavioral difference to watch for.
File System, Networking, Process Migration
File System: std.fs.* → std.Io.Dir / std.Io.File
Nearly every function gained an io parameter. Mechanical changes dominate:
Windows path parsing has been reworked for consistency — windowsParsePath/diskDesignator/diskDesignatorWindows → parsePath, parsePathWindows, parsePathPosix, plus new getWin32PathType.
Selective Directory Walks
New std.Io.Dir.walkSelectively avoids wasted open/close syscalls for directories you'd skip:
varwalker=trydir.walkSelectively(gpa);
deferwalker.deinit();
while (trywalker.next(io)) |entry| {
if (failsFilter(entry)) continue;
if (entry.kind==.directory) trywalker.enter(io, entry);
// ...
}
Walker gains depth() on Entry and leave() for early-bailing from a subdir.
Networking
All of std.net.* has been migrated to std.Io.net.*. Notable:
std's networking path on Windows no longer routes through ws2_32.dll — it uses direct AFD access. (Your own code can of course still link and call ws2_32.dll if you want to; this is only about std.Io.net.*.)
Lock-free primitives (atomics, etc.) do not need the Io interface.
⚠️std.Io.Group is not just a renamed WaitGroup. It is the task-orchestration primitive described under Groups — tied to async/await/cancelation semantics. If you were using WaitGroup purely as a counting latch, you may prefer std.Io.Semaphore or an atomic counter + std.Io.Event.
Clock.resolution is now separately queryable, allowing error.ClockUnsupported / error.Unexpected to be removed from timer error sets (systems with "infinite" resolution are handled gracefully).
Entropy
// Bytes from the Io's RNG:io.random(&buffer);
// std.Random interface on top of Io:constrng_impl: std.Random.IoSource= .{ .io=io };
constrng=rng_impl.interface();
// Cryptographically secure, always from outside the process:tryio.randomSecure(&buffer); // may fail with error.EntropyUnavailable
std.Options.crypto_always_getrandom and crypto_fork_safety are gone — use io.randomSecure when you need process-memory-free entropy.
Compression, Debug Info, Misc
Deflate: Compression Is Back
Zig 0.16 ships a from-scratch deflate compressor (plus Raw store-only and Huffman-only variants), along with a simplified flate decompressor:
Default-level: ~10% faster than zlib, ~1% worse ratio.
Best-level: on par with zlib on perf, ~0.8% worse ratio.
Decompression: ~10% faster than Zig 0.15.
Other compression: lzma, lzma2, xz updated to the new Io.Reader/Io.Writer world.
And standardizes on short, composable concept words:
find — index of a substring
pos — starting-index parameter
last — search from end
linear — naive loop vs. fancy algorithm
scalar — substring is a single element
(Expect gradual renames of indexOf* callsites over time.)
Target.SubSystem Moved
std.Target.SubSystem → std.zig.Subsystem (with a deprecated alias and field-name aliases to keep exe.subsystem = .Windows working).
Build System Changes
--fork=[path] — Override Packages Locally
zig build --fork=/home/andy/dev/dvui
Path points to a directory containing build.zig.zon with name and fingerprint.
Any time the dependency tree resolves a package with matching name+fingerprint, it's replaced with the local path — anywhere in the tree.
Ignores version. Resolves before any fetch.
Ephemeral: drop the flag → pristine dependencies again.
Errors out if nothing matches; prints an info line listing matches so you don't get confused.
Caveat: depends on the new hash format — legacy hash format support has been removed.
Packages Fetched Into Project-Local zig-pkg/
Packages now land in a zig-pkg/ directory next to build.zig, not in the global cache. After fetching and applying paths filters, each package is re-tarballed into $GLOBAL_ZIG_CACHE/p/$HASH.tar.gz so other projects can reuse it.
Requirements now enforced:
build.zig.zonmust have fingerprint.
name must be an enum literal (not a string).
Having the same fingerprint+version with a different hash in the tree is a hard error.
ZIG_BTRFS_WORKAROUND is no longer observed (upstream Linux bug long fixed).
--test-timeout
zig build test --test-timeout 500ms
Forces each test to finish within real time; slow/hung tests are killed and reported. Useful for CI; be mindful of heavy-load false positives.
Build.makeTempPath: removed (it ran in the wrong phase).
WriteFile gained tmp mode and mutate mode.
Build.addTempFiles — placed under tmp/, uncached; cleaned on success.
Build.addMutateFiles — operates in-place on a tmp dir.
Build.tmpPath — shortcut for addTempFiles + WriteFile.getDirectory.
Upgrade: makeTempPath + addRemoveDirTree → addTempFiles + the new WriteFile API.
Misc
std.Build.Step.ConfigHeader now handles leading whitespace for CMake-style configs.
Compiler and Backends
1. C Translation Now Uses Aro
Translate-C is now powered by Vexu/arocc and translate-c — 5,940 lines of C++ dropped from the compiler tree. Compiled lazily on first @cImport. This is a big step toward the broader goal of switching from a library LLVM dependency to a process Clang dependency.
Technically non-breaking, but any difference between Aro and Clang is a bug — report it.
2. LLVM Backend
Experimental incremental compilation support — speeds up bitcode gen (not final EmitObject).
3–7% smaller LLVM bitcode.
~3% faster compile in some cases.
Debug info: fixed for zero-bit-payload unions; type names complete; error set types lowered as enums so error names survive to runtime.
Internal groundwork laid toward parallelizing LLVM IR generation across functions.
Passes 2004/2010 (100%) of behavior tests — still the correctness reference.
(LLDB bug prevents using DWARF variant types for tagged unions / error unions for now.)
3. Reworked Byval Syntax Lowering
The frontend now lowers expressions "byref" until the final load. Fixes:
Array access performance issues.
Surprising aliasing after explicit copy.
Extremely poor codegen in degenerate cases.
4. Reworked Type Resolution
A huge internal change that:
Simplifies the (still in-progress) Zig language spec.
Fixes many bugs — especially around incremental compilation.
Is generally more permissive than before.
Makes dependency-loop errors much clearer (with numbered notes that read like a story).
Causes some previously accepted programs (e.g. a struct using @alignOf(@This())) to fail with a clear dep-loop error.
5. Incremental Compilation
Incremental updates are substantially faster (changes that used to redo most of a build now complete in milliseconds).
No longer produces ghost "dependency loop" errors that don't happen in full builds.
The New ELF Linker (below) is the default for -fincremental targeting ELF.
LLVM backend now supports incremental — meaning compile-error feedback is near-instant even when you're using LLVM.
Usage: zig build -fincremental --watch.
Still off by default (known bugs remain).
6. x86 Backend
11 bug fixes.
Better constant memcpy codegen.
Still the default for Debug mode on several x86_64 targets; faster compile, better debug info, inferior codegen vs LLVM.
Self-hosted backend is now the Debug-mode default on more targets in 0.16.0 — in 0.15.x this was just x86_64-linux. In 0.16.0, it expanded to include x86_64-macos, x86_64-maccatalyst, x86_64-haiku, and x86_64-serenity (look for 🖥️⚡ in the target support table). Other x86_64 targets (freebsd/netbsd/openbsd/windows) still go through LLVM by default. Use -fllvm / -fno-llvm to override.
7. aarch64 Backend
Progress paused for the I/O-interface work. Currently crashes on behavior tests. Expected to pick up after the std churn settles.
8. WebAssembly Backend
Passing 1813/1970 (92%) of behavior tests vs LLVM.
9. .def → Import Library Without LLVM
Zig can now generate MinGW-w64 import libraries from .def files without depending on LLVM — another step toward cutting the LLVM library dependency.
10. Better For-Loop Safety Check Codegen
Looping over slices generates ~30% less code for the safety checks.
11. Windows: Completed Migration to NtDll
All std-lib functionality on Windows now goes through the stable syscall API. The only remaining extern DLL imports are CreateProcessW and the crypt32 cert-chain functions. This yields fewer bugs, less overhead, and full Batch + Cancelation for Windows networking.
Consequence: XP / old-Windows targeting requires a third-party Io implementation that uses higher-level DLLs.
Linker: New ELF Linker
Flag: -fnew-linker on CLI, or exe.use_new_linker = true in build.zig.
Infinite mode picks the most interesting tests automatically; old/explored tests get less time.
Crash dumps — crashing inputs are saved and can be replayed via std.testing.FuzzInputOptions.corpus + @embedFile.
AST Smith found 20 new bugs in zig fmt alone, plus several Parser/PEG inconsistencies.
Toolchain
Library Versions
Library
Version
LLVM / Clang
21.1.0 / 21.1.8
musl
1.2.5 (+ backported security)
glibc (cross)
2.43
Linux headers
6.19
macOS headers
26.4
FreeBSD libc
15.0
WASI libc
commit c89896107d7b
MinGW-w64
commit 38c8142f660b
Loop Vectorization Disabled
An LLVM 21 regression miscompiles Zig itself in common configs. As a safety measure, loop vectorization is disabled entirely until we move to a fixed LLVM. Expect this to persist through 0.17, be fixed in 0.18.
zig libc Expansion
Zig's own libc now provides many more functions (including malloc and friends, plus a big chunk of math). C source files shipped with Zig dropped from 2,270 → 1,873 (-17%):
331 fewer musl sources.
99 fewer MinGW-w64 sources.
WASI actually gained 32 due to newer pthread shims.
If you hit bugs in "musl" or "MinGW-w64" through Zig, report them to Zig's issue tracker — many are now Zig's responsibility.
zig cc / zig c++
Now Clang 21.1.8-based.
9 bugs fixed.
OS Version Requirements
OS
Minimum
DragonFly BSD
6.0
FreeBSD
14.0
Linux
5.10
NetBSD
10.1
OpenBSD
7.8
macOS
13.0
Windows
10
OpenBSD Cross-Compile Support
Dynamic libc stubs + most system headers for OpenBSD 7.8+.
The format specifier grammar ({[pos][spec]:[fill][align][width].[prec]}) and the set of specifiers ({s} {c} {d} {x} {X} {o} {b} {e} {E} {u} {any} {f} {*}) is unchanged from 0.15. See the "Zig Format Specifiers Guide" at the bottom of ZIG-0.15.2.md — it still applies verbatim in 0.16, except:
If you were using std.io.fixedBufferStream, switch to std.Io.Reader.fixed / std.Io.Writer.fixed.
If you were using std.fmt.format to a writer, that's std.Io.Writer.print now.
Anywhere you wrote to stdout via std.fs.File.stdout().writer(&buf) — you now write it through std.Io.File.stdout() with an Io parameter.
Compile-Error Decoder
Common 0.16 errors when porting from 0.15.x and what they usually mean:
Error fragment
Likely cause
Fix
no field or declaration 'cwd' in std.fs (or similar)
You're still calling std.fs.*
Use std.Io.Dir / std.Io.File
expected 2 arguments, found 1 on file.close()
Missing Io parameter
Thread io through, call file.close(io)
expected type 'std.Io', found ...
Function signature needs an Io param
Add io: std.Io and pass through
use of undeclared identifier 'std.Thread.Pool'
Thread pool removed
Use std.Io.async / std.Io.Group
use of undeclared identifier 'std.io.fixedBufferStream'
Removed
std.Io.Reader.fixed(x) / std.Io.Writer.fixed(x)
pointer not allowed in packed struct/union
Field is a pointer in a packed type
Store as usize; convert with @ptrFromInt / @intFromPtr
integer tag type of enum is inferred in extern context
Implicit enum tag in extern
Spell it out: enum(u8) { ... }
inferred backing integer of packed ... has unspecified signedness
Implicit backing int in extern
Use packed struct(u8) / packed union(u16) etc.
returning address of expired local variable '...'
return &x; where x is local
Return by value, or allocate and return the pointer
indexing a vector at runtime is not allowed
vector[runtime_i]
Coerce: const arr: [N]E = vector;
lossy conversion from comptime_int to f32
Integer literal too big for float
Use explicit 123.0 literal or @floatFromInt at comptime
type '...' depends on itself for alignment query here
Struct field alignment references @alignOf(@This())
Break the cycle (compute alignment differently)
dependency loop with length N (multiple notes)
New type resolution caught a cycle
Read the numbered notes top-to-bottom; break any one link
use of undeclared identifier '@Type'
@Type removed
Use @Int/@Struct/@Union/@Enum/@Pointer/@Fn/@Tuple/@EnumLiteral
tried to invoke non-function 'std.Io.Writer.Allocating.writer'
.writer is a field, not a method
Use alloc.writer.print(...) or &alloc.writer (no parens)
expected type 'std.Io.Limit', found 'comptime_int'
Passing bare integer where Io.Limit expected
Use .limited(N) — enum literal method call
unable to find error 'FileTooBig'
Error renamed for limited reads
Switch to error.StreamTooLong
type 'std.Io.File' has no member 'writeAll' with 1 argument
0.15-style one-arg writeAll
Use file.writeStreamingAll(io, bytes)
missing struct field: items (and/or capacity) on std.ArrayListUnmanaged(T)
ArrayListUnmanaged lost field defaults
Replace = .{} / T(){} with = .empty (decl literal)
root source file struct 'mem' has no member named 'trimLeft'
renamed in 0.16
std.mem.trimStart(...) / std.mem.trimEnd(...)
struct 'MyWrapper' has no member named 'empty'
You ran a blanket = .{} → = .empty sed that hit a user-defined struct
Either add pub const empty: MyWrapper = .{}; to the struct, or revert those specific sites to = .{} with explicit sub-field defaults
Stderr dump: error(DebugAllocator): memory address 0x... leaked: after process exit
init.gpa is DebugAllocator in Debug, surfacing pre-existing leaks
See the "⚠️init.gpa is DebugAllocator" section under Juicy Main. Exit code stays 0; tests still pass. Fix in a follow-up PR.
Common Bad Assumptions from 0.15.x
Things that were true in 0.15 and are no longer true in 0.16 — these are the ones AI agents and muscle-memory humans get wrong most often:
"I can call std.fs.cwd() anywhere." — No, you need std.Io.Dir.cwd() and an Io.
"std.Thread.WaitGroup is a lightweight counter." — std.Io.Group replaces it, but is a task orchestrator tied to async semantics. Use Semaphore or atomics if you just want a counter.
"std.Thread.Pool is the way to parallelize." — Gone. Use Io.async / Io.Group.
"@cImport is the right way to use C code." — Still works today (it's deprecated, not removed), but the blessed path is b.addTranslateC in build.zig.
"Packed structs can hold pointers." — No longer. Use usize + @ptrFromInt / @intFromPtr.
"std.os.environ is a global." — Gone. Env lives on init.environ_map (Juicy) or init.environ (Minimal).
"std.crypto.random.bytes gets me entropy anywhere." — Replaced by io.random(&buf) / io.randomSecure(&buf).
"Evented I/O is the default." — Io.Threaded is the default. Io.Evented is experimental.
"@intFromFloat is the float→int conversion." — Use @trunc/@floor/@ceil/@round instead.
"@Type(.{.int=...}) is how I make an integer type at comptime." — Use @Int(.unsigned, N).
"Custom format uses a comptime format-string parameter." — That was 0.14 and earlier; since 0.15, the signature is pub fn format(self, writer: *std.Io.Writer) !void, invoked via {f}.
"*T and *align(1) T are the same type." — They coerce freely, but compare as distinct.
"std.Io.Writer.Allocating.writer() is a method." — It's a field. Use alloc.writer.print(...) or &alloc.writer, not alloc.writer(). (This is one of the easiest 0.16 compile errors to trigger when porting.)
"readFileAlloc's size cap is still a usize." — No — it's now Io.Limit. Write .limited(N) at the call site, not a bare integer. The error for hitting the cap is now error.StreamTooLong, not error.FileTooBig.
"std.ArrayListUnmanaged(T) = .{} still works for an empty list." — Gone. Use .empty. Same for ArrayList(T). Affects direct locals, struct-field defaults, and @splat(.{}).
"std.mem.trimLeft / trimRight are still the names." — They were renamed to trimStart / trimEnd in 0.16. Plain trim is unchanged.
"If I migrate page_allocator → init.gpa, nothing runtime-visible changes." — Wrong on two dimensions. (a) init.gpa is DebugAllocator in Debug and dumps leak traces to stderr at exit. Exit code stays 0 but stderr fills up. (b) DebugAllocator tracking is O(n) in live allocations, which can make allocation-heavy programs hundreds to thousands of times slower in Debug. For short-lived CLIs, init.arena.allocator() is the correct default. See the ⚠️ section under Juicy Main.
"My short-lived CLI should use init.gpa because it's the idiomatic 0.16 default." — Only if you actually need leak tracking. init.arena.allocator() is both faster in Debug (no per-allocation bookkeeping) and cleaner (no leak spam) for programs that do one computation and exit.
Migration Workflow Tactics (lessons from a real 0.15.2 → 0.16.0 port)
This section captures the execution playbook for actually doing a 0.15 → 0.16 migration, informed by an end-to-end port of a ~7,300-line parser generator. It's aimed at a future AI (or human) undertaking the same work.
Phase 0 — Empirical baseline before any edits
Don't trust release notes alone for exact API spellings. Before touching code:
zig build 2>&1| tee /tmp/migration-baseline.log
Zig 0.16 compiles lazily and typically reports one error at a time, so this is a probe, not a census. That's fine — it tells you the first thing that breaks, which drives Phase 1.
Phase 1 — Fix one API family at a time, compile between each
Going wide on multiple API families simultaneously makes error attribution hard. The sequence that worked best:
Between each: zig build, read the next error, proceed. Don't batch.
Phase 2 — Verify with compiler before trusting your memory of the API
0.16 has enough subtle API-shape changes (e.g., Allocating.writer is a field, not a method; ArrayListUnmanaged(T){} no longer works) that even the release notes can mislead. Before mass-editing, read the actual stdlib:
zig env # find std_dir# then for each API you'll touch, grep or read the actual source:# e.g., /opt/homebrew/Cellar/zig/0.16.0/lib/zig/std/Io/Writer.zig
grep -n "pub const Allocating"<std_dir>/Io/Writer.zig
This is a 5-minute investment that eliminates ~3-5 compile-fix-recompile cycles.
Phase 3 — Test with real workloads, not just "does it build"
After getting a green build, run the program on its largest realistic input in Debug mode and time it. If you see 10×+ regression vs 0.15:
Check for init.gpa in a workload that allocates heavily or retains most allocations — this is the DebugAllocator slowdown (see the ⚠️ section under Juicy Main).
Swap in init.arena.allocator() for short-lived CLIs; for long-running programs, actually fix the leaks.
Useful grep-level safety nets
Before claiming a migration is complete, sweep for stragglers:
# Old APIs that should have no callers left:
rg "std\.fs\." --type zig # → should be 0
rg "std\.mem\.(trimLeft|trimRight)\b" --type zig # → should be 0
rg "std\.io\.(fixedBufferStream|GenericWriter|GenericReader|AnyReader|AnyWriter)\b" --type zig
rg "std\.process\.argsAlloc\b" --type zig
rg "std\.heap\.ThreadSafeAllocator\b" --type zig
rg "std\.Thread\.Pool\b" --type zig
# Old container initialization syntax:
rg "ArrayListUnmanaged\([^)]*\) = \.\{\}" --type zig
rg "ArrayListUnmanaged\([^)]*\)\{\}" --type zig
# Old comments/docs (code may be migrated but comments stale):
rg "// .*std\.fs\." --type zig
rg "// .*std\.io\.(GenericWriter|GenericReader|fixedBufferStream)" --type zig
Zero matches on all = high confidence the diff is complete.
Sed tactics that worked
For mass-migratable patterns, sed sweeps saved ~100 StrReplace calls:
# The big one - container default initialization:
sed -i '''s/= \.{}/= .empty/g' yourfile.zig
# Specific renames:
sed -i '' -e 's/std\.mem\.trimLeft/std.mem.trimStart/g' \
-e 's/std\.mem\.trimRight/std.mem.trimEnd/g' yourfile.zig
Important caveats:
= .{} → = .empty is nearly always correct, but breaks if a non-container struct also uses = .{} as a default. Fix by adding pub const empty: MyStruct = .{}; to the wrapper struct.
After any sed sweep: git diff before recompiling, visually scan for obvious mistakes in the diff.
.{} WITHOUT = (e.g., in @splat(.{}), createFile(path, .{}), std.debug.print("...", .{})) is safe to leave as-is — only = .{} assignment form is the problem. The above sed only matches = .{} so it won't touch the others.
Handling generated/vendored files
If your project contains code generated from some source (e.g., parser generators, protobuf output), think carefully about regeneration vs manual editing:
Templates in the generator must emit 0.16-compatible code so regenerated files are correct.
Checked-in generated files may have 0.15 patterns that won't compile standalone under 0.16. If the generator imports them lazily (via @import without field-level access), Zig 0.16's lazy field analysis lets them coexist — you don't need to edit the generated file, just fix the generator's templates and regenerate.
After regeneration, diff against git. Diffs should be exclusively the expected migrations (e.g., .{} → .empty). Any unexpected drift is a bug.
What release notes reliably do tell you
API renames and signature shapes (trust them as starting points, verify exact argument order against stdlib).
Removed items.
Philosophy changes (e.g., "I/O as an Interface").
What release notes don't tell you reliably
Performance characteristics of new default allocators under specific workloads.
Field-default removals on widely-used types (release notes often focus on APIs, not data-layout changes on heavily-used structs).
Ergonomic papercuts like "this works in all cases except inside a template string for generated code."
Which 0.15 program patterns that worked "by accident" will now break (e.g., page_allocator's never-fail behavior hiding OOM paths).
The migration heuristic: release notes tell you what changed; real workloads tell you what matters.
Recommended peer-review hygiene
Migrations benefit from having a second AI or human review at least once after initial drafting and again after execution:
Pre-execution review catches over-confident claims (e.g., "this API is definitely spelled X") and forces empirical verification.
Post-execution review catches issues the executor was too close to notice (silent-failure catch blocks, dead imports, scope creep into unrelated cleanup).
Tools: the user-ai MCP's discuss conversation is a good fit — it preserves context across multiple rounds, so pre-migration critique, mid-migration status checks, and post-migration review can all share the same conversation thread.
Roadmap
Upcoming (per release notes):
0.17 — short cycle; upgrade to LLVM 22; finish separating the "make" phase (build runner) from the "configure" phase (build.zig).
Beyond:
Complete and stabilize the language.
Finish the aarch64 backend; make it the default for Debug.
Enhance linkers, remove LLD dependency, full incremental support.
Improve the fuzzer to be competitive with AFL et al.
Switch from LLVM library dependency to Clang process dependency.
1.0 — Tier 1 targets will require a formal bug policy.
Key Takeaways
"Juicy Main" + Io is the new mental model. Threading an Io through your code is like threading an Allocator. Embrace it; don't fight it.
Mechanical diffs dominate. Most file-system changes are just adding io as the first arg. Lean on the compiler.
Dependency-loop errors get much better. If you see one, read the numbered notes — they're a story.
@Type is gone. Replace with the new focused builtins; they read more like the syntax they produce.
@cImport will eventually disappear entirely. Move to b.addTranslateC now.
Packed types are stricter. Explicit backing integers in extern contexts, no pointers, equal-width fields.
Incremental + new ELF linker are genuinely usable.zig build -fincremental --watch is a different experience.
Network code on Windows is fundamentally faster (direct AFD, no ws2_32.dll).
Cancelation is spelled with a single 'l'. Adopt it in your APIs.
Expect bugs. 0.16 contains 345 fixed bugs and still plenty remaining — "zig 1.0" is the target for stability guarantees. Report early, report often.