· 5 min read ·

How Zig Rewrote Its Own Compiler While Keeping the Lights On

Source: zig

The Zig programming language shipped its self-hosted compiler as the default in 0.10.0 and removed the original C++ implementation entirely in 0.11.0. The full account from the Zig team is worth reading, but three years on, the architectural decisions deserve a closer look, especially for anyone who thinks about what compilers actually are versus what they appear to be.

What Was Wrong With Stage1

The original compiler, called stage1, was written in C++. That alone is not a problem; lots of serious compilers are. The problems were structural.

Comptime evaluation in stage1 was implemented twice. There was a C++ interpreter that ran Zig code at compile time, and separately, a runtime code generator that produced LLVM IR. These two paths could diverge. A value computed at comptime might behave differently than the equivalent value computed at runtime, because they were driven by different engines with different implementations. That kind of inconsistency quietly corrupts user trust in a language.

The second problem was the compilation model. Stage1 generated LLVM IR in a single monolithic pass over the whole program. There was no seam where you could stop, cache something, and resume. Incremental compilation, where you only reprocess what changed, was architecturally impossible without starting over. For a language that wants fast iteration cycles, this ceiling was real.

The third problem was maintenance. Running two compilers in parallel, stage1 in C++ and stage2 being developed alongside it, meant every language change had to be implemented twice. The team was paying this tax throughout the transition.

The Stage2 Architecture

Stage2 introduced a clean pipeline with distinct representations at each stage.

Source is parsed into an AST. Rather than the conventional tree-of-heap-nodes where each node is separately allocated and pointer-linked, Zig’s AST uses a struct-of-arrays layout. Nodes are stored in flat arrays, accessed by index. This is friendlier to CPU caches and reduces allocator pressure.

The AST is then lowered to ZIR, Zig Intermediate Representation. ZIR is untyped; it captures the structure of the code before types are resolved. Crucially, ZIR is cached. If you change one function and recompile, only the ZIR for changed functions is invalidated. The rest can be reused.

Semantic analysis, Sema, takes ZIR and produces AIR, Analyzed IR. AIR is typed and in static single assignment form. Each AIR instruction is a tagged union, 64 bits wide, stored in a flat array and arena-allocated. The flat layout keeps things compact and avoids pointer chasing during codegen.

Sema is also the comptime interpreter. This is the key architectural difference from stage1. There is one engine that handles both comptime evaluation and type-checked compilation. When Sema encounters a comptime block, it evaluates it using the same machinery it uses for everything else. The dual-implementation problem disappears. Sema.zig, the file that implements this, is over 100,000 lines, making it one of the largest single source files in open source. It is not elegant in the sense of being small, but it is coherent.

Multiple Backends, One Frontend

After Sema produces AIR, multiple backends can consume it. The LLVM backend is used for optimized release builds. It produces the highest-quality code but is the slowest path through the compiler.

The native x86_64 backend skips LLVM entirely. It takes AIR and emits machine code directly. For debug builds, this path is 5 to 10 times faster than stage1. The Zig compiler itself went from roughly 10 seconds to under 2 seconds in debug mode. That is the difference between pausing to think and staying in flow.

There are also backends for WebAssembly, SPIR-V, ARM, and a C backend that emits C source code. The C backend matters for portability: any platform with a C compiler can compile Zig, without needing a native Zig binary to start.

The backend interface is deliberately narrow. Each backend implements a small set of functions: one to generate code for a function, one to handle incremental updates to a declaration, one to finalize output. That constraint is what makes incremental compilation composable rather than ad hoc.

Bootstrapping

Bootstrapping a self-hosted compiler is a chicken-and-egg problem. You need a Zig compiler to compile Zig, but you need to compile Zig to get one.

Zig’s solution uses a small WebAssembly binary, zig1.wasm, checked into the repository. This is a compiled snapshot of a previous version of the compiler. A tiny C program implements a WASM interpreter and uses it to run zig1.wasm, which compiles the current Zig source into a native binary. That native binary then compiles itself. The WASM binary is platform-independent, so the same bootstrapping sequence works everywhere.

The C backend provides an alternative path. If you have only a C compiler and nothing else, you can use C backend output from a previous Zig version to bootstrap. Two independent routes to a working binary is a meaningful redundancy.

The Public Transition

Go 1.5 in 2015 also moved from a C compiler to a self-hosted one, but Go’s approach was a mechanical translation: the C code was converted to Go without redesigning the architecture. Rust moved from OCaml to Rust before the language was public, so users never depended on the old compiler. Zig did this differently. Stage1 was the production compiler that real users depended on throughout the stage2 development period. The team maintained both simultaneously, shipping stage2 as the default only after it surpassed stage1 in capability.

The milestone arrived in August 2022, when the self-hosted compiler passed stage1 on Zig’s own test suite. Zig 0.10.0 shipped with stage2 as default. Zig 0.11.0 removed stage1 entirely.

Compiler as Library

The clean pass boundaries in stage2 have consequences beyond compile speed. Because the compiler is structured around well-defined transformations between representations, it can be embedded.

ZLS, the Zig Language Server, links against the Zig compiler directly. It can call into the parser and Sema incrementally, on the specific file that changed, without running a full compilation. Stage1’s global state and tight coupling to LLVM made that kind of embedding impractical. The build system can also call into the compiler incrementally, and hot-reload, patching a running binary in place rather than relinking, becomes feasible because the compiler can produce updated code for a single function without invalidating the whole program.

None of these are possible when your compiler is a single-pass monolith that talks to LLVM through a global pipeline.

What This Demonstrates

The transition is interesting not because self-hosting is inherently virtuous but because of the specific problems it solved. Comptime consistency, incremental compilation, embedding, and fast debug cycles were all structurally blocked by stage1. Stage2 unblocked them by being designed around the problem rather than around the prior implementation.

The 100,000-line Sema.zig is sometimes cited as evidence of complexity. It is complex. But it is one thing, with one job, rather than two things doing the same job differently. That consolidation is what made the rest of the architecture possible.

Was this interesting?