⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.2281+83624acf6What's newOn an older Zig?

What Changed in Zig 0.17

Updated · Code verified against Zig 0.17.0-dev.2281+83624acf6

Zig 0.17 has not been released yet.

Master calls itself 0.17.0-dev, and that’s the compiler this guide is built with.

This page lists what changed between the 0.16.0 release and master.

It covers the changes you will hit in everyday code: the language, the parts of std most programs use, and build.zig.

It leaves out compiler internals, OS ports and changes to the linker.

Nobody has announced a release date, so this page doesn’t guess one.

Master keeps moving, and the list can still change before 0.17.0 ships.

Coming from an Older Zig covers the bigger jumps from before 0.16, such as the buffered writers and main taking an Init.

This page covers only the last step.

How this page was checked

The program below runs every new shape on this page.

CI compiles and runs it against master every night, like every other snippet here.

It uses the new names throughout, so 0.16.0 refuses to 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 are 0.16.0 code, and most of them no longer compile, so they can’t have a Run button.

We compiled each one with both compilers on 2026-09-25, against 0.16.0 and 0.17.0-dev.2056.

The error messages quoted below come from that run.

At a glance

What0.16.0MasterOld code
Array repeat"-" ** 5@splat('-')fails
Struct and enum reflectioninfo.fields[i].nameinfo.field_names[i]fails
Error capture on cleanuperrdefer |err|catch |err| and returnfails
C headers@cImportb.addTranslateCfails
Build modebuiltin.mode == .Debugbuiltin.optimize == .debugfails on ==
Leak-checking allocatorDebugAllocator(.{})SafeAllocatorcompiles, deprecated
Format into memorystd.fmt.bufPrint, allocPrintstd.mem.print, Allocator.printcompiles, deprecated
Empty bit set.initEmpty().emptyfails
Last item of a listgetLast()last()compiles, deprecated
containsAtLeastScalarcount, then elementelement, then countcompiles, gives a different answer
Passing args to zig build runb.argsrun_cmd.addPassthruArgs()fails
Custom build stepsmakeFna tool run with addRunArtifactfails

The last column is the one to read first.

“Fails” is the easy case, because the compiler finds every one of them for you.

“Compiles, deprecated” keeps working for now.

The containsAtLeastScalar row is the one to search your code for by hand.

** is gone

// 0.16.0
const rule = "-" ** 12;
snippets/01-getting-started/zig-0-17.zigrepeat
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});
}

Master no longer reads ** as one operator.

It sees two * signs, so the error talks about something else:

error: binary operator '*' has whitespace on one side, but not the other

If you see that message on a line with **, this is the cause.

++ for joining two arrays still works.

→ Arrays

@typeInfo returns parallel arrays

In 0.16.0, a struct’s @typeInfo held one fields array.

Each element had a name, a type and a default value.

Master splits that into separate arrays that line up by index.

// 0.16.0
inline for (@typeInfo(Point).@"struct".fields) |f| {
    std.debug.print("{s}\n", .{f.name});
}
snippets/01-getting-started/zig-0-17.zigreflection
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'

The same split happened to every kind of type:

Kind0.16.0Master
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]

A default value is now read with field_attrs[i].defaultValue(T).

The helpers in std.meta went with it.

std.meta.fields(T) is now a compile error that says deprecated in favor of @typeInfo.

std.meta.Int and std.meta.Tuple are gone.

Use the @Int and @Tuple builtins, which already existed in 0.16.0.

→ Enums · Inline Loops

errdefer |err| is gone

errdefer could capture the error that was on its way out.

That capture has been removed.

// 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, catch it where it happens, do the work, and return it:

snippets/01-getting-started/zig-0-17.zigparseLogged
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 where you write the handling.

→ Errors · Defer

@cImport is gone

@cImport, @cInclude, @cDefine and @cUndef have all been removed.

error: invalid builtin function: '@cImport'

C headers are now translated by the build system.

b.addTranslateC reads the header, and your code imports the result like any other module.

→ Importing C shows the whole setup, with snippets checked against master.

Build modes are lowercase

The four modes are now .debug, .safe, .fast and .small.

The type is std.lang.Optimize, and you read the current one from builtin.optimize.

// 0.16.0
if (builtin.mode == .Debug) { ... }
error: no field named 'Debug' in enum 'lang.Optimize'
snippets/01-getting-started/zig-0-17.zigdescribe
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 every old spelling fails.

A switch with .Debug or .ReleaseFast prongs still compiles, and so does const o: std.builtin.OptimizeMode = .ReleaseFast.

Both go through aliases kept for the move.

A comparison with == does not, so an if on the build mode is where you’ll see the error.

builtin.mode itself still works, and 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, -O fast and -Doptimize=fast are accepted, and the old spellings still work.

→ Build Modes

std.builtin is now std.lang

std.builtin.Type is std.lang.Type, std.builtin.Endian is std.lang.Endian, and so on.

The old name is an alias for now, and the std source says it will be removed after 0.17.0.

The target fields moved too.

builtin.os.tag is builtin.target.os.tag, and builtin.cpu, builtin.abi and builtin.object_format went the same way.

std.gpu was renamed to std.spirv, with no alias.

Allocators and formatting

snippets/01-getting-started/zig-0-17.zigmemory
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

Four changes are in that function.

DebugAllocator is now SafeAllocator.

It takes its backing allocator as an argument, and deinit() returns the number of leaks instead of .ok or .leak.

The old name still compiles as an alias.

std.fmt.allocPrint is now Allocator.print, so you call gpa.print.

std.fmt.bufPrint is now std.mem.print.

Both old names are aliases.

std.heap.stackFallback is gone, with no alias.

BufferFirstAllocator does the same job: it hands out memory from your buffer until the buffer runs out, then asks the backing allocator.

A few smaller ones were removed outright:

0.16.0Master
std.fmt.bufPrintZ(&buf, fmt, args)std.mem.printSentinel(&buf, fmt, args, 0)
gpa.dupeZ(u8, s)gpa.dupeSentinel(u8, s, 0)
std.heap.MemoryPoolAlignedstd.heap.memory_pool.Aligned

→ Allocators · Formatting

Containers

snippets/01-getting-started/zig-0-17.zigcontainers
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 lost initEmpty() and initFull().

Declare the type and write .empty or .full, which also worked in 0.16.0.

error: struct 'bit_set.Integer(64)' has no member named 'initEmpty'

The error names Integer, not Static, because a small Static set is an Integer set underneath.

The bit set types were renamed as well.

StaticBitSet is std.bit_set.Static, and DynamicBitSetUnmanaged is std.bit_set.Dynamic.

The old names are aliases.

std.EnumSet lost initEmpty() and initFull() the same way.

ArrayList.getLast() and getLastOrNull() are now one method, last(), which returns an optional.

DoublyLinkedList.pop() is now popLast().

Both old names are aliases.

ArrayHashMap.setKey no longer allocates, so it lost its allocator argument.

→ Set Operations on the Cheap · Stacks

The one the compiler won’t catch

std.mem.containsAtLeastScalar swapped its last two arguments.

In 0.16.0 it took the count, then the element.

On master 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 master the same call asks “are there at least 5 copies of 1?” and returns false.

Both arguments are integers, so both orders type-check.

There is no error and no deprecation warning.

If your code calls this function, check every call by hand.

In 0.16.0 there was also a containsAtLeastScalar2 with the new order.

Master removed it, so code that had already switched to it fails to compile, which is the easy case.

Other std moves

0.16.0MasterOld code
std.Io.File.OpenFlagsstd.Io.Dir.OpenFileOptionsfails
std.Io.File.CreateFlagsstd.Io.Dir.CreateFileOptionsfails
std.ascii.indexOfIgnoreCasestd.ascii.findIgnoreCasefails
std.mem.writePackedIntNative(...)std.mem.writePackedInt(..., .native)fails
std.mem.byteSwapAllFieldsstd.mem.byteSwapcompiles, deprecated

New builtins

snippets/01-getting-started/zig-0-17.zigbuiltins
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 you never divide by zero.

std.math.divCeil is still there when you want an error back instead.

@backingInt reads the integer under an enum or a packed struct.

@fromBackingInt goes the other way.

They cover what @intFromEnum, @enumFromInt and @bitCast did for those types.

The old builtins still work, and the master language reference marks them deprecated.

@bitCast can now also produce an enum, which 0.16.0 refused.

build.zig

A project made with zig init on 0.16.0 fails here, before the compiler reaches any of your own code.

zig build run -- args

The generated build.zig passed command-line arguments like this:

// 0.16.0
if (b.args) |args| run_cmd.addArgs(args);
error: no field named 'args' in struct 'Build'

Master’s zig init now writes one line instead:

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 like verbose and release_mode moved to b.graph.

Custom steps

In 0.16.0 you could write a step in Zig with makeFn, and it ran inside the build runner.

That’s gone.

build.zig now 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.

Write the work as a small Zig program, build it with b.addExecutable, and run it with b.addRunArtifact.

Renames that still compile

0.16.0Master
b.lazyDependencyb.dependencyLazy
b.runAllowFailb.runFallible
run.addOutputFileArgrun.addOutputFileArg2
run.addDirectoryArgrun.addDirectoryArg2
run.addArtifactArgrun.addArtifactArg2
run.addFileContentArgrun.addFileContentArg2
run.addPrefixedFileArg and the other addPrefixed* helpersthe *2 version, with .prefix in its options

Flags

zig build no longer accepts --global-cache-dir, --zig-lib-dir or --build-runner.

--zig-lib=<path> replaces --zig-lib-dir.

Check your CI scripts for these.

What stayed the same

Most of a normal build.zig compiles unchanged.

b.createModule, b.addExecutable with root_module, b.addTest, b.addRunArtifact, b.option, b.standardTargetOptions and b.standardOptimizeOption all work as they did.

→ Zig Build

What didn’t change

We compared the public declarations of these between 0.16.0 and master, and found no difference:

  • std.json
  • std.Thread
  • std.Io.Dir and std.Io.Reader
  • the reader and writer on std.Io.File
  • std.process.Init

The bigger changes, such as main taking std.process.Init, the buffered writers, the filesystem moving onto Io and the unmanaged ArrayList, all happened before 0.16.0.

So did std.mem.find replacing indexOf.

If your code already builds on 0.16.0, none of those will affect you.

Coming from an Older Zig covers them.