What Changed in Zig 0.17
The Zig team released 0.17.0 on 2 October 2026. This page gives a list of the changes between the 0.16.0 release and the 0.17.0 release. The list includes the changes that have an effect on everyday code: the language, the parts of std that most programs use, and build.zig. The list does not include compiler internals, OS ports and changes to the linker.
This guide now uses master, which calls itself 0.18.0-dev. The changes after 0.17.0 are not on this page.
Migrate from an Older Zig explains the larger changes from before 0.16, for example the buffered writers and the Init parameter of main. This page explains only the last step.
The checks on this page
The program below runs each new shape on this page. Each night, CI compiles and runs the program against master, as it does for each other snippet on this site. The program uses only the new names, thus 0.16.0 cannot compile it.
const std = @import("std");
const Point = struct { x: i32, y: i32 };
const Color = enum(u8) { red = 1, green = 2, blue = 4 };
fn repeat(out: *std.Io.Writer) !void {
// `"-" ** 12` compiled on 0.16.0. On master `**` is gone.
const rule: [12]u8 = @splat('-');
try out.print("{s}\n", .{&rule});
}
fn reflection(out: *std.Io.Writer) !void {
// Names, types and values are parallel arrays now, not one `fields` array.
const s = @typeInfo(Point).@"struct";
inline for (s.field_names, s.field_types) |name, T| {
try out.print("Point.{s}: {s}\n", .{ name, @typeName(T) });
}
const e = @typeInfo(Color).@"enum";
inline for (e.field_names, e.field_values) |name, value| {
try out.print("Color.{s} = {d}\n", .{ name, value });
}
}
fn parse(text: []const u8) !u8 {
return std.fmt.parseInt(u8, text, 10);
}
fn parseLogged(out: *std.Io.Writer, text: []const u8) !u8 {
// `errdefer |err|` no longer parses. Catch, log, and return the error.
return parse(text) catch |err| {
try out.print("could not parse \"{s}\": {t}\n", .{ text, err });
return err;
};
}
fn describe(mode: std.lang.Optimize) []const u8 {
// The build modes are lowercase: .debug, .safe, .fast, .small.
return switch (mode) {
.debug => "debug: every check on, no optimisation",
.safe => "safe: optimised, checks kept",
.fast => "fast: optimised, checks removed",
.small => "small: optimised for size, checks removed",
};
}
fn memory(out: *std.Io.Writer) !void {
// DebugAllocator is now SafeAllocator, and it takes its backing allocator.
var safe: std.heap.SafeAllocator = .init(std.heap.page_allocator, .{});
const gpa = safe.allocator();
// std.fmt.allocPrint is now a method on the allocator.
const owned = try gpa.print("{d} + {d} = {d}", .{ 2, 3, 2 + 3 });
try out.print("gpa.print: {s}\n", .{owned});
gpa.free(owned);
// std.fmt.bufPrint is now std.mem.print.
var buf: [32]u8 = undefined;
const text = try std.mem.print(&buf, "{d} items", .{3});
try out.print("mem.print: {s}\n", .{text});
// stackFallback is gone. BufferFirstAllocator uses the buffer first.
var stack: [64]u8 = undefined;
var first: std.heap.BufferFirstAllocator = .init(&stack, gpa);
const small = try first.allocator().alloc(u8, 16);
try out.print("BufferFirstAllocator gave {d} bytes\n", .{small.len});
first.allocator().free(small);
// deinit returns a leak count instead of .ok or .leak.
try out.print("leaks: {d}\n", .{safe.deinit()});
}
fn containers(out: *std.Io.Writer) !void {
// initEmpty() is gone. Declare the type and use .empty.
var seen: std.bit_set.Static(64) = .empty;
seen.set(3);
seen.set(10);
try out.print("bits set: {d}\n", .{seen.count()});
// getLast() and getLastOrNull() became one method that returns an optional.
var backing: [3]u8 = undefined;
var list: std.ArrayList(u8) = .initBuffer(&backing);
try out.print("last of an empty list: {?d}\n", .{list.last()});
list.appendSliceAssumeCapacity(&.{ 4, 8, 15 });
try out.print("last: {?d}\n", .{list.last()});
// The element now comes before the count. The old order still compiles.
const rolls = [_]u8{ 6, 6, 1, 6 };
try out.print("at least three sixes: {}\n", .{std.mem.containsAtLeastScalar(u8, &rolls, 6, 3)});
}
fn builtins(out: *std.Io.Writer) !void {
try out.print("@divCeil(7, 2) = {d}\n", .{@divCeil(@as(u32, 7), 2)});
try out.print("@backingInt(Color.blue) = {d}\n", .{@backingInt(Color.blue)});
const c: Color = @fromBackingInt(2);
try out.print("@fromBackingInt(2) = {t}\n", .{c});
}
pub fn main(init: std.process.Init) !void {
var buf: [2048]u8 = undefined;
var file_writer = std.Io.File.stdout().writerStreaming(init.io, &buf);
const out = &file_writer.interface;
try repeat(out);
try reflection(out);
try repeat(out);
_ = parseLogged(out, "300") catch {};
try out.print("{s}\n", .{describe(.fast)});
try repeat(out);
try memory(out);
try repeat(out);
try containers(out);
try repeat(out);
try builtins(out);
try out.flush();
}The “0.16.0” blocks are different. They contain 0.16.0 code, and most of them do not compile now. Thus they cannot have a Run button.
On 2026-09-25, the author compiled each of these blocks with the two compilers, 0.16.0 and 0.17.0-dev.2056. The error messages below come from that run.
On 2026-10-25, the author compiled the blocks again with the 0.17.0 release, and each block gave the same error. The program above also runs on 0.17.0, and its output is the same as the output that CI checks on master.
Summary
| What | 0.16.0 | 0.17.0 | Old code |
|---|---|---|---|
| Array repeat | "-" ** 5 | @splat('-') | fails |
| Struct and enum reflection | info.fields[i].name | info.field_names[i] | fails |
| Error capture on cleanup | errdefer |err| | catch |err| and return | fails |
| C headers | @cImport | b.addTranslateC | fails |
| Build mode | builtin.mode == .Debug | builtin.optimize == .debug | fails on == |
| Allocator that finds leaks | DebugAllocator(.{}) | SafeAllocator | compiles, deprecated |
| Format into memory | std.fmt.bufPrint, allocPrint | std.mem.print, Allocator.print | compiles, deprecated |
| Empty bit set | .initEmpty() | .empty | fails |
| Last item of a list | getLast() | last() | compiles, deprecated |
containsAtLeastScalar | count, then element | element, then count | compiles, gives a different answer |
Arguments to zig build run | b.args | run_cmd.addPassthruArgs() | fails |
| Custom build steps | makeFn | a tool run with addRunArtifact | fails |
Read the last column first:
- “Fails” is the easy condition, because the compiler finds each of these changes for you.
- “Compiles, deprecated” continues to work for now.
- For the
containsAtLeastScalarrow, you must search your code yourself.
** is gone
// 0.16.0
const rule = "-" ** 12;
fn repeat(out: *std.Io.Writer) !void {
// `"-" ** 12` compiled on 0.16.0. On master `**` is gone.
const rule: [12]u8 = @splat('-');
try out.print("{s}\n", .{&rule});
}0.17.0 does not read ** as one operator. It sees two * signs, thus the error is about a different problem:
error: binary operator '*' has whitespace on one side, but not the other
If you see that message on a line with **, the removal of ** is the cause. ++ still joins two arrays.
→ Arrays
@typeInfo returns parallel arrays
In 0.16.0, the @typeInfo of a struct had one fields array. Each element had a name, a type and a default value. 0.17.0 divides that array into separate arrays. The arrays agree by index.
// 0.16.0
inline for (@typeInfo(Point).@"struct".fields) |f| {
std.debug.print("{s}\n", .{f.name});
}
fn reflection(out: *std.Io.Writer) !void {
// Names, types and values are parallel arrays now, not one `fields` array.
const s = @typeInfo(Point).@"struct";
inline for (s.field_names, s.field_types) |name, T| {
try out.print("Point.{s}: {s}\n", .{ name, @typeName(T) });
}
const e = @typeInfo(Color).@"enum";
inline for (e.field_names, e.field_values) |name, value| {
try out.print("Color.{s} = {d}\n", .{ name, value });
}
}error: no field named 'fields' in struct 'lang.Type.Struct'
Each kind of type has the same change:
| Kind | 0.16.0 | 0.17.0 |
|---|---|---|
| struct | .fields[i].name, .type | .field_names[i], .field_types[i], .field_attrs[i] |
| enum | .fields[i].name, .value, .is_exhaustive | .field_names[i], .field_values[i], .mode == .exhaustive |
| union | .fields | .field_names, .field_types, .field_attrs |
| declarations | .decls[i].name | .decl_names[i] |
| pointer | .is_const, .alignment | .attrs.@"const", .attrs.@"align" |
| function | .params[i].type | .param_types[i] |
| error set | .error_set.?[i].name | .error_set.error_names.?[i] |
To read a default value now, use field_attrs[i].defaultValue(T).
The helpers in std.meta also changed. std.meta.fields(T) is now a compile error with the text deprecated in favor of @typeInfo. std.meta.Int and std.meta.Tuple are gone. Use the @Int and @Tuple builtins. These builtins were already in 0.16.0.
→ Enums · Inline Loops
errdefer |err| is gone
In 0.16.0, errdefer could capture the error that the function returned. 0.17.0 removed that capture.
// 0.16.0
errdefer |err| std.log.err("parse failed: {t}", .{err});
error: expected block or expression, found '|'
Plain errdefer without a capture still works. When you need the error itself, do these steps where the error occurs:
- Catch the error.
- Do the work.
- Return the error.
fn parseLogged(out: *std.Io.Writer, text: []const u8) !u8 {
// `errdefer |err|` no longer parses. Catch, log, and return the error.
return parse(text) catch |err| {
try out.print("could not parse \"{s}\": {t}\n", .{ text, err });
return err;
};
}could not parse "300": Overflow
The function still returns the same error to its caller. The only change is the location of the code that handles the error.
@cImport is gone
0.17.0 removed @cImport, @cInclude, @cDefine and @cUndef.
error: invalid builtin function: '@cImport'
Now the build system translates C headers. b.addTranslateC reads the header, and your code imports the result as it imports each other module.
→ Import C shows the full configuration, with snippets that CI checks against master.
Build modes are lowercase
The four modes are now .debug, .safe, .fast and .small. The type is std.lang.Optimize. To get the current mode, read builtin.optimize.
// 0.16.0
if (builtin.mode == .Debug) { ... }
error: no field named 'Debug' in enum 'lang.Optimize'
fn describe(mode: std.lang.Optimize) []const u8 {
// The build modes are lowercase: .debug, .safe, .fast, .small.
return switch (mode) {
.debug => "debug: every check on, no optimisation",
.safe => "safe: optimised, checks kept",
.fast => "fast: optimised, checks removed",
.small => "small: optimised for size, checks removed",
};
}Not all of the old names fail. These forms still compile, through aliases for the old names:
- A
switchwith.Debugor.ReleaseFastprongs. const o: std.builtin.OptimizeMode = .ReleaseFast.
A comparison with == does not compile. Thus you will see the error at an if on the build mode.
builtin.mode itself still works. The std source marks it for removal after 0.18.0.
build.zig has the same change. if (optimize == .ReleaseFast) fails, and if (optimize == .fast) works. On the command line, Zig accepts -O fast and -Doptimize=fast, and the old names also still work.
std.builtin is now std.lang
std.builtin.Type is std.lang.Type, std.builtin.Endian is std.lang.Endian, and the other declarations have the same change. For now, the old name is an alias. The std source marks the alias for removal after 0.17.0.
The target fields also moved. builtin.os.tag is builtin.target.os.tag. builtin.cpu, builtin.abi and builtin.object_format moved in the same way.
std.gpu has the new name std.spirv, and there is no alias.
Allocators and formatting
fn memory(out: *std.Io.Writer) !void {
// DebugAllocator is now SafeAllocator, and it takes its backing allocator.
var safe: std.heap.SafeAllocator = .init(std.heap.page_allocator, .{});
const gpa = safe.allocator();
// std.fmt.allocPrint is now a method on the allocator.
const owned = try gpa.print("{d} + {d} = {d}", .{ 2, 3, 2 + 3 });
try out.print("gpa.print: {s}\n", .{owned});
gpa.free(owned);
// std.fmt.bufPrint is now std.mem.print.
var buf: [32]u8 = undefined;
const text = try std.mem.print(&buf, "{d} items", .{3});
try out.print("mem.print: {s}\n", .{text});
// stackFallback is gone. BufferFirstAllocator uses the buffer first.
var stack: [64]u8 = undefined;
var first: std.heap.BufferFirstAllocator = .init(&stack, gpa);
const small = try first.allocator().alloc(u8, 16);
try out.print("BufferFirstAllocator gave {d} bytes\n", .{small.len});
first.allocator().free(small);
// deinit returns a leak count instead of .ok or .leak.
try out.print("leaks: {d}\n", .{safe.deinit()});
}gpa.print: 2 + 3 = 5
mem.print: 3 items
BufferFirstAllocator gave 16 bytes
leaks: 0
That function shows four changes.
DebugAllocator is now SafeAllocator. It takes its backing allocator as an argument. deinit() returns the number of leaks, not .ok or .leak. The old name still compiles as an alias.
std.fmt.allocPrint is now Allocator.print, thus you call gpa.print. std.fmt.bufPrint is now std.mem.print. The two old names are aliases.
std.heap.stackFallback is gone, and there is no alias. BufferFirstAllocator does the same work. It gives memory from your buffer until the buffer is full. Then it gets memory from the backing allocator.
0.17.0 also removed some smaller declarations, with no alias:
| 0.16.0 | 0.17.0 |
|---|---|
std.fmt.bufPrintZ(&buf, fmt, args) | std.mem.printSentinel(&buf, fmt, args, 0) |
gpa.dupeZ(u8, s) | gpa.dupeSentinel(u8, s, 0) |
std.heap.MemoryPoolAligned | std.heap.memory_pool.Aligned |
→ Allocators · Formatting
Containers
fn containers(out: *std.Io.Writer) !void {
// initEmpty() is gone. Declare the type and use .empty.
var seen: std.bit_set.Static(64) = .empty;
seen.set(3);
seen.set(10);
try out.print("bits set: {d}\n", .{seen.count()});
// getLast() and getLastOrNull() became one method that returns an optional.
var backing: [3]u8 = undefined;
var list: std.ArrayList(u8) = .initBuffer(&backing);
try out.print("last of an empty list: {?d}\n", .{list.last()});
list.appendSliceAssumeCapacity(&.{ 4, 8, 15 });
try out.print("last: {?d}\n", .{list.last()});
// The element now comes before the count. The old order still compiles.
const rolls = [_]u8{ 6, 6, 1, 6 };
try out.print("at least three sixes: {}\n", .{std.mem.containsAtLeastScalar(u8, &rolls, 6, 3)});
}bits set: 2
last of an empty list: null
last: 15
at least three sixes: true
Bit sets do not have initEmpty() and initFull() now. Declare the type and write .empty or .full. These two forms also worked in 0.16.0.
error: struct 'bit_set.Integer(64)' has no member named 'initEmpty'
The error names Integer, not Static. The reason is that a small Static set is an Integer set internally.
The bit set types also have new names. StaticBitSet is std.bit_set.Static, and DynamicBitSetUnmanaged is std.bit_set.Dynamic. The old names are aliases.
std.EnumSet also lost initEmpty() and initFull().
ArrayList.getLast() and getLastOrNull() are now one method, last(). This method returns an optional. DoublyLinkedList.pop() is now popLast(). The two old names are aliases.
ArrayHashMap.setKey does not allocate now. Thus it does not have an allocator argument.
→ Set Operations on the Cheap · Stacks
The change that the compiler does not find
std.mem.containsAtLeastScalar has its last two arguments in the opposite order now. In 0.16.0, it took the count, then the element. In 0.17.0, it takes the element, then the count.
// 0.16.0: "is there at least 1 copy of 5?"
std.mem.containsAtLeastScalar(usize, &.{ 5, 5, 1 }, 1, 5)
On 0.16.0, that call returns true. On 0.17.0, the same call asks “are there at least 5 copies of 1?” and returns false.
The two arguments are integers, thus the two orders are correct types. There is no error and no deprecation warning. If your code calls this function, examine each call yourself.
In 0.16.0, there was also a containsAtLeastScalar2 with the new order. 0.17.0 removed it. Thus code that already uses containsAtLeastScalar2 does not compile, and that is the easy condition.
Other std moves
| 0.16.0 | 0.17.0 | Old code |
|---|---|---|
std.Io.File.OpenFlags | std.Io.Dir.OpenFileOptions | fails |
std.Io.File.CreateFlags | std.Io.Dir.CreateFileOptions | fails |
std.ascii.indexOfIgnoreCase | std.ascii.findIgnoreCase | fails |
std.mem.writePackedIntNative(...) | std.mem.writePackedInt(..., .native) | fails |
std.mem.byteSwapAllFields | std.mem.byteSwap | compiles, deprecated |
New builtins
fn builtins(out: *std.Io.Writer) !void {
try out.print("@divCeil(7, 2) = {d}\n", .{@divCeil(@as(u32, 7), 2)});
try out.print("@backingInt(Color.blue) = {d}\n", .{@backingInt(Color.blue)});
const c: Color = @fromBackingInt(2);
try out.print("@fromBackingInt(2) = {t}\n", .{c});
}@divCeil(7, 2) = 4
@backingInt(Color.blue) = 4
@fromBackingInt(2) = green
@divCeil divides and rounds up. Like @divTrunc, it assumes that the divisor is never zero. If you want an error instead, std.math.divCeil is still available.
@backingInt reads the integer below an enum or a packed struct. @fromBackingInt does the opposite operation. For those types, these builtins do the work of @intFromEnum, @enumFromInt and @bitCast. The old builtins still work, and the 0.17.0 language reference marks them deprecated.
@bitCast can now also make an enum. 0.16.0 did not accept that.
build.zig
A project from zig init on 0.16.0 fails on 0.17.0. It fails before the compiler gets to your own code.
zig build run -- args
The generated build.zig passed command-line arguments in this way:
// 0.16.0
if (b.args) |args| run_cmd.addArgs(args);
error: no field named 'args' in struct 'Build'
On 0.17.0, zig init writes this one line:
run_cmd.addPassthruArgs();
Paths on b
b.build_root, b.install_path, b.install_prefix, b.cache_root, b.pathFromRoot() and b.getInstallPath() are gone.
The project root is now b.root. Settings such as verbose and release_mode moved to b.graph.
Custom steps
In 0.16.0, you could write a step in Zig with makeFn, and the step ran in the build runner. That feature is gone.
Now build.zig only describes the build. A separate process reads that description and runs the steps.
error: no field named 'id' in struct 'Build.Step.StepOptions'
There is no direct replacement for a custom makeFn. Do these steps:
- Write the work as a small Zig program.
- Build the program with
b.addExecutable. - Run the program with
b.addRunArtifact.
Renames that still compile
| 0.16.0 | 0.17.0 |
|---|---|
b.lazyDependency | b.dependencyLazy |
b.runAllowFail | b.runFallible |
run.addOutputFileArg | run.addOutputFileArg2 |
run.addDirectoryArg | run.addDirectoryArg2 |
run.addArtifactArg | run.addArtifactArg2 |
run.addFileContentArg | run.addFileContentArg2 |
run.addPrefixedFileArg and the other addPrefixed* helpers | the *2 version, with .prefix in its options |
Flags
zig build does not accept --global-cache-dir, --zig-lib-dir or --build-runner now. --zig-lib=<path> replaces --zig-lib-dir. Look for these flags in your CI scripts.
The parts that did not change
Most of a normal build.zig compiles without a change. b.createModule, b.addExecutable with root_module, b.addTest, b.addRunArtifact, b.option, b.standardTargetOptions and b.standardOptimizeOption all work as before.
What did not change
On 2026-09-25, a comparison of the public declarations in 0.16.0 and in master (0.17.0-dev.2056) found no difference for each item in this list:
std.jsonstd.Threadstd.Io.Dirandstd.Io.Reader- the reader and writer on
std.Io.File std.process.Init
The larger changes occurred before 0.16.0. For example, main started to take std.process.Init, the writers got a buffer, the filesystem functions moved onto Io, and ArrayList became unmanaged. std.mem.find also replaced indexOf before 0.16.0.
If your code already builds on 0.16.0, those changes have no effect on your code. Migrate from an Older Zig explains them.