· 6 min read ·

Zig 0.16.0 and the Compiler Infrastructure That's Been Earning This Moment

Source: simonwillison

The Zig project named its 0.16.0 release “Juicy Main,” and the choice of words says something. You earn that kind of confidence in a release name when the development branch has genuinely been productive. Simon Willison flagged the release notes as worth reading, and they are, but the deeper story is how this release fits into a multi-year effort to build a compiler that can rebuild itself efficiently, incrementally, and without leaning on LLVM as a crutch for developer iteration speed.

Where Zig Is in Its Arc

Zig has been in pre-1.0 territory for a long time by design. Andrew Kelley and the core team have been explicit that API stability is not a promise before 1.0, and the release notes for almost every minor version include breaking changes in the standard library. This is not irresponsibility. It is a deliberate strategy: the language specification and the compiler implementation are being refined in tandem, and locking APIs prematurely creates constraints that outlast their original rationale.

The self-hosted compiler replaced the original C++ bootstrap in the 0.10/0.11 era. That was a significant milestone, but it was also just the foundation for something more ambitious. Since then, the compiler team has been building toward an incremental compilation model that changes the character of the development loop entirely.

The Incremental Compilation Work

The most technically ambitious thing in Zig’s development is not a language feature. It is the incremental compilation infrastructure. This is not the variety of incremental compilation that other build systems offer, where you track which object files are stale and skip relinking unchanged ones. Zig’s model is semantic: the compiler tracks individual declarations, functions, and the dependency graph between them, invalidating only the specific semantic units that were affected by a source change.

The model works roughly like this. Declarations are parsed and analyzed lazily. The compiler maintains a graph of what depends on what. When a file changes, it identifies the affected nodes, re-analyzes that slice, and patches the output binary in place where possible. The result is that a large Zig codebase can behave more like a hot-reload environment than a traditional compiled language. You change a function body, the compiler touches only the reachable affected declarations, and the binary is ready.

A prerequisite for this was the native x86_64 backend. LLVM is excellent for optimized code, but it operates at a level of abstraction that makes the fine-grained control required for incremental patching difficult. The native backend handles debug builds; LLVM handles release builds. This split lets the compiler optimize for different objectives without compromise. Fast iteration during development, optimized output when it matters.

For developers coming from Go or Rust, this reframes the question of what compiled languages can feel like in practice. Go has always prioritized compile speed at the architectural level. Rust has improved incrementally but is constrained by the complexity of borrow checking and monomorphization. Zig’s approach is more surgical: rather than making each full compile faster, it makes full compiles rarer.

Comptime: The Feature That Compounds

One aspect of Zig that does not change dramatically release-to-release but continues to compound in value is the comptime system. Where C++ reaches for templates and Rust reaches for trait-bound generics, Zig uses comptime parameters: values known at compile time that the compiler uses to specialize code, with no separate mechanism beyond ordinary function calls.

fn Stack(comptime T: type, comptime capacity: usize) type {
    return struct {
        items: [capacity]T,
        len: usize,

        const Self = @This();

        pub fn push(self: *Self, item: T) !void {
            if (self.len >= capacity) return error.Overflow;
            self.items[self.len] = item;
            self.len += 1;
        }

        pub fn pop(self: *Self) ?T {
            if (self.len == 0) return null;
            self.len -= 1;
            return self.items[self.len];
        }
    };
}

const IntStack = Stack(i32, 16);

Stack(i32, 16) and Stack(f64, 8) are distinct types, generated from the same function, evaluated at compile time. There is no template instantiation mechanism separate from the language semantics. It is function calls that return types, and the compiler evaluates them like any other comptime expression.

The diagnostic implications are meaningful. Template errors in C++ are notoriously difficult to read because the error messages expose the full instantiation stack in terms of the template machinery. Zig comptime errors are reported in terms of the function calls and values that produced them, because that is all they are. The abstraction does not leak because there is no abstraction layer to leak.

Error Handling: Explicit by Construction

Zig’s error handling is unchanged in its fundamentals across recent releases, which is a sign that the design is right. There are no exceptions. Functions that can fail return error union types, written as ErrorSet!ReturnType. Callers handle errors explicitly or propagate them with try.

const ConfigError = error{
    NotFound,
    PermissionDenied,
    ParseFailure,
};

fn loadConfig(
    allocator: std.mem.Allocator,
    path: []const u8,
) ConfigError!Config {
    const file = try std.fs.cwd().openFile(path, .{});
    defer file.close();

    const bytes = try file.readToEndAlloc(allocator, 64 * 1024);
    defer allocator.free(bytes);

    return parseConfig(bytes) catch return ConfigError.ParseFailure;
}

try unwraps a result or returns the error to the caller. errdefer runs cleanup code specifically on error returns, complementing defer which always runs on scope exit. Together they produce deterministic resource cleanup without the stack unwinding overhead or dynamic dispatch of exception systems.

The tradeoff is more explicit handling at call sites. The benefit is that control flow is visible in the source text. You can read a Zig function and know exactly where it can fail, what errors it produces, and where those errors go, without consulting documentation or tracing runtime behavior.

The Package Ecosystem Catching Up

The build.zig.zon manifest format and zig fetch have been maturing since their introduction in 0.11. The model is simple: packages are declared as URL-plus-content-hash pairs, fetched on demand, cached, and exposed to the build script as modules.

// build.zig.zon
.{
    .name = "myproject",
    .version = "0.1.0",
    .dependencies = .{
        .httpz = .{
            .url = "https://github.com/karlseguin/http.zig/archive/refs/tags/v0.9.0.tar.gz",
            .hash = "1220...",
        },
    },
}

The build system itself is a Zig program. build.zig is not a configuration file with a fixed schema; it is code, with access to the standard library and the build API. This means the build system can do whatever Zig can do. Conditional compilation, platform detection, code generation, custom build steps, all of it expressed in the same language as the project itself, with the same tools for debugging.

This is a design point worth sitting with. CMake, Meson, Bazel, Gradle: build systems have historically been distinct from the languages they build, with their own syntax, their own semantics, their own learning curves. Zig collapses that. The Zig build system documentation treats this as a first-class feature, and in practice it means the barrier to writing a custom build step is approximately zero.

What “Juicy Main” Actually Means

In open source projects, the character of a release reflects the character of what the development branch has been doing. “Juicy Main” as a release name is a signal that the main branch has been consistently productive, that the changes have been worth making, and that the team feels good about what shipped.

For Zig specifically, that productivity has been defined by compiler infrastructure work more than by language features. The language design has been coherent for years. What has been catching up is the tooling: the incremental compiler, the native backends, the package manager, the build system ergonomics. Those pieces are now materially closer to where the language design has been for a while.

The full 0.16.0 release notes are dense with specifics across all of these dimensions. For developers already in the Zig ecosystem, the upgrade will require the usual audit of standard library breaking changes. For developers watching from outside, this is a release worth understanding as a marker: not because any single feature changes the calculus, but because the accumulated infrastructure work is starting to show up in the development experience in ways that matter.

Was this interesting?