The most important change in Zig 0.17.0 is not a language feature. It is the build system being split into two separate executables: one that evaluates your build.zig script (the configurer), and one that executes the build graph (the maker). This restructuring solves a problem that has been quietly bothering build systems for years: every time you edit your build script, the entire build system had to be rebuilt from source.
The Problem: Build Scripts That Reprogram the Build System
Most build systems treat the build configuration file as a program. Zig is no exception: build.zig is Zig source code that runs at configure time to declare what steps exist, what dependencies connect them, and what options are available. The trouble is that this program runs inside the build system own process. When you change build.zig, the build system itself must be rebuilt.
This creates a feedback loop. The build system cannot cache its own configuration because the configuration logic is the thing being changed. Every invocation after a build.zig edit starts from scratch: recompile the build runner, re-evaluate the script, then execute the graph. For small projects this is a rounding error. For large projects with complex build scripts, it compounds.
The previous design also forced the build system to observe certain arguments at configure time. If your build.zig checked whether scdoc was installed to decide whether to build man pages, that probe poisoned the configuration cache. The cache could not be reused across invocations because the configuration depended on the state of the host system at the moment it ran.
The Solution: Separate the Configurer From the Maker
Zig 0.17.0 breaks the build system into two processes. The configurer process evaluates build.zig and produces a compact binary serialization of the build graph. The maker process consumes that serialization and executes the steps.
+-------------------+ binary +-------------------+
| Configurer | --------------->| Maker |
| (evaluates | serialization | (executes the |
| build.zig) | | build graph) |
+-------------------+ +-------------------+
The maker executable is built once and remains unmodified when build.zig changes. It is built with optimizations enabled, which matters more now that --watch and --fuzz modes exist. The configurer can be skipped entirely for certain CLI flags, meaning some zig build invocations bypass configuration altogether and go straight to the maker.
This separation also makes configuration serializable in a format that third-party tooling can consume. That format is the foundation of the new Build Server Protocol.
Configure Cache Poisoning: Naming the Side Effects
The split between configurer and maker introduced a precise vocabulary for a concept that was previously implicit: configure cache poisoning. A poisoned cache means the configure logic had side effects or did something the cache system could not track.
The distinction is subtle but important. A Run step that prints output has side effects at make time, which is expected and does not poison the cache. But checking for the existence of scdoc at configure time to choose a default option does poison the cache, because the result depends on host state the cache cannot see.
Zig 0.17.0 provides explicit alternatives to poisoning:
// Instead of probing the host at configure time:
const scdoc_path = b.findProgram(&.{ "scdoc" }, &.{});
// Declare the dependency explicitly:
std.Build.dependOnFileContents(b, "config.txt");
std.Build.dependOnDirectoryContents(b, "docs/");
Four declaration functions cover the common cases: file contents, file metadata (size, inode, mtime), directory contents, and directory metadata. When you declare dependencies instead of probing, the cache stays pure and the configurer can be skipped on identical inputs.
The findProgram function still exists for cases where you genuinely need the result at configure time. A new findProgramLazy function returns a LazyPath that defers the search to make time, keeping the cache clean.
Build Server Protocol: Exposing the Build Graph
With configuration serialized, the build system can serve it to external clients. Passing --listen=- starts the Build Server Protocol, which allows IDEs and other tools to inspect the build graph statically, receive notifications when steps start and complete, and request specific steps to build.
zig build --listen=-
# Build server now accepts protocol messages on stdin/stdout
The protocol is still early. It exposes post-configuration information: available steps, options, dependencies. Planned additions include module names and multiplexed compiler server protocol for type information, refactoring, and editing capabilities.
This is a breaking change for ZLS, the Zig language server. The maker and configurer split prevents ZLS from working with 0.17.0 in its current form. The Zig and ZLS teams are collaborating to restore and exceed the previous functionality through the build server protocol.
Incremental Compilation: The Payoff
The build system restructuring is not an end in itself. Its payoff is incremental compilation. Zig 0.17.0 fixes enough bugs in the ELF linker and the incremental compilation machinery that most projects targeting x86_64-linux can now use it.
zig build -fincremental --watch
# Listens for source changes and performs incremental rebuilds
The new ELF linker introduced in 0.16.0 gained full x86_64 support, static and shared library generation, GOT generation, copy relocations, GNU symbol versioning, DWARF debug information, and symbol hash table generation. It is not yet at feature parity with the legacy linker, but it is capable of building the vast majority of Zig projects on x86_64-linux. The legacy linker is disabled by default for that target.
The next release plans to eliminate the legacy ELF linker entirely and introduce a new Mach-O linker with incremental compilation support for macOS targets.
The Standard Library Gets a Safer Allocator
The build system is the headline, but the standard library changes matter for application code. std.heap.DebugAllocator is replaced by SafeAllocator, a thread-safe allocator with hard guarantees:
// SafeAllocator guarantees:
// - deinit reports all leaks and frees backing memory
// - allocation mismatches panic or segfault
// - allocations from other SafeAllocator instances with
// different canaries cause a panic
// - double frees and resize/remap/free races panic or segfault
Every allocation is trailed by an AllocFooter containing metadata and stack traces, protected by a checksum to catch corruption from overwrites. Small allocations go into linearly-filled buckets; large ones go directly to the backing allocator. The allocator does not reuse memory, which means most writes after free cause a segmentation fault or are detected and reported.
Benchmarks published in the release notes show the new allocator reduces peak RSS by roughly half and cuts wall time by about a quarter when building the standard library tests, compared to the previous DebugAllocator under the same conditions.
Language Changes Worth Auditing
The @bitCast builtin was redefined to operate on the logical bit representation of a value rather than its in-memory layout. For integer and float types on little-endian targets, the behavior is unchanged. The new definition is endian-agnostic, which matters for cross-platform code. However, casts involving extern struct or extern union types are no longer permitted, and the change can break existing code without triggering a compile error. The release notes recommend auditing any @bitCast uses that involve array or vector types.
Two new builtins replace deprecated ones:
// Old (deprecated):
const n = @intFromEnum(my_enum);
const e = @enumFromInt(MyEnum, 42);
// New in 0.17.0:
const n = @backingInt(my_enum);
const e = @fromBackingInt(MyEnum, 42);
@backingInt works with all enums, tagged unions, and bitpacks with explicit backing integer types. @fromBackingInt infers its result type and performs safety checks for invalid tag values. Passing undefined as a backing integer for an enum yields safety-checked Illegal Behavior.
The @cImport mechanism, deprecated in 0.16.0, is now removed entirely. C translation moves to an external package with its own release cadence:
zig fetch --save git+https://codeberg.org/ziglang/translate-c
The formal grammar (grammar.peg) and the handwritten parser now agree, after a tool was written to generate a recursive descent parser from the grammar file and use it as a fuzz oracle. This unblocks future grammar changes and specification work.
What Breaks and What to Watch For
The maker and configurer split breaks response file use cases for std.Build.Step.Run. ZLS does not work with 0.17.0 yet. The @bitCast change is a silent break for code using array or vector types. Several notable regressions exist: compiler-rt fails to compile for soft float on x86, std.debug.simple_panic fails to compile, and weak Zig libc symbols cannot be reliably overridden.
LLVM loop vectorization remains disabled as a workaround for a miscompilation that affects the Zig compiler. The fix is merged into LLVM main but is not in LLVM 22, the version Zig 0.17.0 ships. Zig 0.18.0 will upgrade to LLVM 23 and re-enable the optimization.
The release spans 5 months, 206 contributors, and 925 commits. The roadmap to 1.0 continues: language proposals are being decided (around 25 accepted, 125 rejected this cycle), with 23 proposals still open on Codeberg and 61 on the legacy GitHub tracker. The build system split is infrastructure that the remaining language decisions will depend on.
Originally published on Dispatch.
Top comments (0)