# What Changed in Zig 0.17

> The changes between 0.16.0 and master, checked on both compilers.

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](https://www.ziglang.in/learn/getting-started/coming-from-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.

```zig
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();
}
```

*Runnable: compiled to WebAssembly and executed by CI against Zig master. (`01-getting-started.zig-0-17`)*

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

| What | 0.16.0 | Master | 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 `==` |
| Leak-checking allocator | `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** |
| Passing args to `zig build run` | `b.args` | `run_cmd.addPassthruArgs()` | fails |
| Custom build steps | `makeFn` | a tool run with `addRunArtifact` | fails |

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

```zig
// 0.16.0
const rule = "-" ** 12;
```

<SnippetSource name="01-getting-started.zig-0-17" decl="repeat" />

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](https://www.ziglang.in/learn/language-basics/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.

```zig
// 0.16.0
inline for (@typeInfo(Point).@"struct".fields) |f| {
    std.debug.print("{s}\n", .{f.name});
}
```

<SnippetSource name="01-getting-started.zig-0-17" decl="reflection" />

```
error: no field named 'fields' in struct 'lang.Type.Struct'
```

The same split happened to every kind of type:

| Kind | 0.16.0 | Master |
| --- | --- | --- |
| 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](https://www.ziglang.in/learn/language-basics/enums/) · [Inline Loops](https://www.ziglang.in/learn/language-basics/inline-loops/)

## `errdefer |err|` is gone

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

That capture has been removed.

```zig
// 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:

<SnippetSource name="01-getting-started.zig-0-17" decl="parseLogged" />

```
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](https://www.ziglang.in/learn/language-basics/errors/) · [Defer](https://www.ziglang.in/learn/language-basics/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](https://www.ziglang.in/learn/working-with-c/cimport/) 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`.

```zig
// 0.16.0
if (builtin.mode == .Debug) { ... }
```

```
error: no field named 'Debug' in enum 'lang.Optimize'
```

<SnippetSource name="01-getting-started.zig-0-17" decl="describe" />

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](https://www.ziglang.in/learn/build-system/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

<SnippetSource name="01-getting-started.zig-0-17" decl="memory" />

```
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.0 | Master |
| --- | --- |
| `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](https://www.ziglang.in/learn/standard-library/allocators/) · [Formatting](https://www.ziglang.in/learn/standard-library/formatting/)

## Containers

<SnippetSource name="01-getting-started.zig-0-17" decl="containers" />

```
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](https://www.ziglang.in/learn/how-to/bitsets/) · [Stacks](https://www.ziglang.in/learn/standard-library/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.

```zig
// 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.0 | Master | 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

<SnippetSource name="01-getting-started.zig-0-17" decl="builtins" />

```
@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:

```zig
// 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:

```zig
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.0 | Master |
| --- | --- |
| `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` 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](https://www.ziglang.in/learn/build-system/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](https://www.ziglang.in/learn/getting-started/coming-from-older-zig/) covers them.
