· 7 min read ·

What 'Juicy Main' Tells You About Where Zig Is Headed

Source: simonwillison

Zig releases don’t come with marketing copy. The project is pragmatic to a fault about what it ships and when. So when the 0.14.0 release notes arrived with the tagline “Juicy Main”, it was worth pausing on. Simon Willison flagged it as worth reading, which for a language that still hasn’t reached 1.0 is a meaningful signal.

The tagline is a bit of a Zig inside joke. In Zig, pub fn main() !void is where every program begins. But in compiler development terms, “main” also refers to the core compilation pipeline that has been under reconstruction for the better part of three years. The “juicy” part is the team admitting this release is dense with things that actually matter.

The Incremental Compilation Story

Zig’s most ambitious long-term technical commitment has been incremental compilation. Not the kind where you cache object files and relink. True incremental compilation: the compiler tracks dependencies between declarations, re-analyzes only what changed, and emits updated machine code surgically into the running binary.

This is hard to build correctly. Most languages that claim incremental compilation actually mean something weaker. Go’s build cache is fast because Go compilation is already fast, not because it avoids redundant work at a fine grain. Rust’s incremental compilation operates at the crate level, which helps but still forces full re-analysis of any crate with a changed dependency. cargo check feels fast partly because it skips codegen entirely.

Zig’s approach targets the declaration level. If you change the body of one function and that function’s type signature stays the same, only that function gets re-analyzed and re-emitted. If you change a type that other declarations depend on, only those dependents get invalidated. The compiler maintains an InternPool, a deduplication table for types, values, and other compiler artifacts, that makes it possible to detect what actually changed versus what merely got re-parsed.

The engineering behind this involves several moving pieces. The compiler tracks a dependency graph alongside the program’s semantic graph. Every time semantic analysis (Sema, the major compilation pass where types get resolved and checked) touches a piece of information, it records that dependency. Later, when a source file changes, the compiler can walk that graph and determine the minimal re-analysis frontier.

This is not novel in concept. Haskell’s GHC has done something similar for years. IDEs like rust-analyzer and clangd maintain incremental models for interactive use. But building it into the primary compiler as the default compilation mode is a different kind of commitment.

Two Backends, One Language

Zig has maintained two compiler backends for a while now, and understanding the distinction matters for understanding what this release cycle has been doing.

The LLVM backend is what you get for release builds. LLVM’s optimization passes are mature, produce excellent code, and support the full range of target architectures that Zig promises. When you run zig build -Doptimize=ReleaseFast, you are essentially feeding LLVM-IR to one of the best optimizing compilers in existence. The cost is compilation speed. LLVM is a large, slow machine even when you use it correctly.

The self-hosted backends are where Zig’s iteration speed lives. The x86-64 backend bypasses LLVM entirely, going from Zig’s internal AIR (Analyzed Intermediate Representation) directly to machine code. It does minimal optimization. The output runs slower than LLVM-optimized code. But it compiles fast, fast enough that incremental recompilation of a changed function can happen in milliseconds rather than seconds.

For debug workflows, which is where most development time is spent, this tradeoff is correct. You want fast feedback, not fast binaries. The self-hosted backend has been gradually covering more of the language’s feature surface, and each release extends what you can actually build and run through it.

The split also has a structural benefit: the self-hosted backend’s development has forced the compiler team to maintain a clean separation between semantic analysis and code generation. AIR is the contract between the two. If that contract is clean, adding more backends later (WebAssembly, RISC-V, etc.) becomes a well-defined problem rather than an archaeology project.

What Comptime Has to Do With All of This

Zig’s compile-time execution system (comptime) is one of its most distinctive features and one of the harder ones to handle in an incremental model.

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

        pub fn push(self: *@This(), item: T) void {
            self.items[self.top] = item;
            self.top += 1;
        }
    };
}

const IntStack = Stack(i32);

This is Zig’s approach to generics. There are no generic type parameters in the traditional sense. Instead, comptime T: type means: evaluate T at compile time, which must produce a type value. The function then runs at compile time and returns a new type.

The comptime interpreter is essentially a small virtual machine that runs a subset of Zig at compile time. It has to handle arbitrary computation, including loops, conditional logic, and function calls, while the compiler is simultaneously doing semantic analysis. This creates complex dependency chains: the result of semantic analysis might depend on comptime execution, which might depend on semantic analysis of other declarations.

Making this incremental-friendly required careful design of the InternPool. Comptime-generated types need to be memoized: if Stack(i32) was already computed and nothing that computation depended on has changed, the cached result is valid. Getting the cache invalidation right here is genuinely subtle work.

The Build System Is Still Zig

One underrated aspect of Zig’s development experience is that build.zig is just a Zig program that calls into the build system API. There’s no separate DSL, no YAML, no Makefile dialect. If you know Zig, you know how to write build scripts.

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const exe = b.addExecutable(.{
        .name = "myapp",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });

    b.installArtifact(exe);
}

The package manager introduced in 0.12.0 uses build.zig.zon (Zig Object Notation) for declaring dependencies, with content-addressed fetching via hashes. This is a model that works: the hash in the manifest pins the exact content, not a version range, and zig fetch populates a local cache. It avoids the accidental mutable dependency problems that have plagued JavaScript ecosystems.

As incremental compilation matures, the build system benefits directly. The build runner can track which source files changed and request only the necessary re-analysis from the compiler, rather than treating each zig build invocation as a fresh start.

Async’s Long Absence

One of the more painful decisions in Zig’s recent history was removing async/await in 0.11.0. The old async implementation was tied to the stage1 compiler in ways that made it incompatible with the new self-hosted compiler, and rather than port a design the team wasn’t satisfied with, they removed it with a promise to redesign.

This has been a real gap. Writing network code or anything that benefits from cooperative multitasking without async is doable but more awkward than it should be. The community has been waiting, and the redesign has been a recurring topic on Zig’s issue tracker and forums.

The new async model is expected to be structured around what Zig calls “colorless” functions, avoiding the function coloring problem that plagues async/await in languages like JavaScript, Python, and Rust, where calling an async function from a sync context requires either blocking or propagating async through the entire call chain. Whether this release delivers on that vision or continues to iterate on the foundation is one of the things that makes the release notes worth reading closely.

Where This Fits in Zig’s Path to 1.0

Zig 1.0 has been the acknowledged goal for several years, and the team has been honest that the language spec is not frozen and the standard library is not stable. Each pre-1.0 release can and does break things. This is the cost of building a language without cutting corners on the design.

The incremental compilation work is foundational to that goal in a specific way: it’s not a feature users directly experience, but it sets the ceiling on how good the development experience can be at 1.0. A language with a fast, correct incremental compiler can support interactive tools, fast test cycles, and responsive IDE integration. A language that relies entirely on full recompilation is working against its users as projects grow.

Zig’s compiler is self-hosted, written in Zig, which means the team experiences whatever the compiler’s performance characteristics are directly. They are the primary users of the thing they’re building. That feedback loop tends to produce honest priorities.

The “Juicy Main” tagline reads as an acknowledgment that this release has significant weight, that the core compilation infrastructure has crossed a threshold worth naming. That’s not a marketing claim for a language that has been characteristically quiet about promises. It’s more like a checkpoint: the foundation is getting solid enough to build on.

For anyone watching systems programming language development, Zig 0.14.0 is worth following closely, not because it’s done, but because the trajectory is becoming clear.

Was this interesting?