Sang's Blog

Go, Rust, and Zig

For most of computing history, the debate about programming languages was really a debate about one thing: who is responsible for making sure the program does not break. At one extreme was C, which trusted the programmer completely and gave them absolute control over the machine. At the other extreme was Java, which trusted the programmer not at all and wrapped every operation in a runtime that mediated every memory allocation, every thread, and every type cast. Every other language fell somewhere on this spectrum, and the spectrum was treated as a single axis from control to safety. The more control you wanted, the more responsibility you had to accept. The more safety you wanted, the more you had to surrender to the runtime.

This single-axis model held for three decades because it described the available options accurately enough. But in the past fifteen years, three languages have broken the axis entirely. They do not fall on the old spectrum between C and Java. They redefine who is responsible in fundamentally different ways. Go gives responsibility to the runtime, but a runtime that is lighter, faster, and more transparent than Java’s. Rust gives responsibility to the compiler, a party the old debate never even considered. Zig gives responsibility to the programmer, but with guardrails that C never provided. All three are succeeding in production, at scale, in systems programming — the domain where C and C++ were supposed to be untouchable. Understanding why requires understanding not what features they have, but where each one places the burden of correctness.

Go’s answer is that the runtime should handle the hard problems, and the programmer should handle only the logic specific to their application. This sounds like Java’s philosophy, and in the abstract it is. But the implementation is so different that it produces a different kind of programming. Go’s runtime handles memory through a concurrent garbage collector tuned for low latency rather than maximum throughput. It handles concurrency through lightweight threads that multiplex thousands of logical tasks onto a handful of operating system threads, growing and shrinking stacks dynamically. The programmer writes synchronous code. The runtime makes it asynchronous underneath. The key design choice is that Go’s runtime is not optional. There is no way to compile Go without the garbage collector, without the scheduler, without the runtime type information that enables interfaces. The runtime is the language. You accept its presence in exchange for never thinking about memory management or thread scheduling again. The trade-off is that you cannot write an operating system kernel — the runtime is too heavy for that context — but you can write everything above the kernel, and in a world where everything runs on Linux, everything is above the kernel.

This bet — that for most programmers, most of the time, the runtime should carry the burden — has paid off decisively. Go dominates cloud infrastructure and networked services because in that domain the hard part is the service logic, not the memory layout. The runtime handles the plumbing. The developer handles the business. The deployment is a single static binary. The operational story is clean. For I/O-bound services talking to databases and message queues, the runtime’s overhead is negligible and the trade-off of surrendering kernel-level control is irrelevant.

Rust’s answer is the most radical because it introduces a party the old debate did not have: the compiler as the enforcer of correctness. In C, the compiler trusts the programmer. In Java, the compiler trusts the runtime. In Rust, the compiler trusts no one — it verifies memory safety, data race freedom, and null pointer safety at compile time through static analysis baked into the type system. There is no garbage collector. There is no runtime mediating every operation. There is just the compiler, refusing to produce a binary until it has proven that the code satisfies the ownership and borrowing rules.

The mechanism is the borrow checker. Every value has exactly one owner at any given time. You can lend references — borrow — but the compiler tracks every lifetime. If a reference could outlive the value it points to, the program does not compile. You can have one mutable reference or any number of immutable references, but never both simultaneously. This eliminates data races on shared state by construction, because a data race requires one writer and at least one reader operating on the same memory without synchronization. The compiler simply forbids the condition that makes races possible.

This is not a runtime check or a linter warning. It is a refusal to compile. And it is the reason Rust’s learning curve is a cliff. Beginners fight the compiler for weeks. Experienced Rust programmers describe the first month as a period of constant, humbling rejection. Then something shifts. You realize the patterns the compiler was rejecting were genuinely fragile. The shared mutable state you were trying to use was a bug waiting to manifest. The reference you wanted to keep alive was a use-after-free that would have corrupted memory silently. The compiler was right, and you were wrong, and the only reason you did not know you were wrong is that C would have let you run the code and find out months later.

The results have been decisive. The organisations that have paid the highest cost for memory unsafety — browser vendors, cloud infrastructure companies, operating system maintainers — have concluded that putting responsibility on the compiler is worth the fight. The cost is compile time. Rust’s analysis is computationally expensive, and large projects take minutes to build. This will never match Go’s sub-second builds because proving memory safety requires solving problems that are fundamentally harder than just generating code. The question Rust asks is whether you value build time or runtime safety, and the answer depends on what you are building. For a component that parses untrusted input from the internet, the compiler’s guarantee is worth any compile time. For a command-line tool that runs for three seconds, it might not be.

Zig’s answer is the subtlest because it looks, at first glance, like a return to C’s philosophy: the programmer is responsible, and the language gets out of the way. There is no garbage collector, no borrow checker, no runtime scheduler. You allocate memory. You free it. You manage concurrency through the operating system’s primitives. The language is thin enough that you can see the assembly on the other side. This sounds like C, and the resemblance is deliberate — Zig’s creator spent years writing C and concluded that C’s fundamental model was correct. The problem was not the philosophy. It was the execution.

C’s execution fails in two specific ways, and Zig fixes both. First, undefined behavior. In C, signed integer overflow, out-of-bounds array access, and reading uninitialized memory are all undefined — the compiler is allowed to assume they never happen and optimize accordingly, which means a buffer overflow can cause the compiler to delete your security checks entirely. Zig eliminates this by inserting runtime checks in safe build modes. In debug mode, overflowing an integer panics with a stack trace. Accessing an array out of bounds panics with a stack trace. In release-fast mode, the checks come off and the compiler optimizes aggressively, but only after you have verified correctness in safe mode. This is not Rust’s approach. Rust proves safety at compile time by refusing to compile unsafe patterns. Zig allows unsafe patterns but catches their consequences at runtime. The difference is philosophical: Rust believes the programmer cannot be trusted with dangerous operations. Zig believes the programmer can be trusted, but needs to know immediately when they have made a mistake.

Second, the toolchain. C’s preprocessor is a separate language with no type checking, designed for an era when compilers were too slow to do metaprogramming any other way. Zig replaces it with comptime — ordinary Zig code that executes during compilation, type-checked by the same compiler, producing error messages that point to the actual line that caused the problem. Zig ships its own linker, its own libc, and its own compiler-rt. Building a static Linux binary from macOS is a single command with no additional toolchains. Zig can import C headers directly by parsing them as part of compilation. A Zig project can gradually replace C modules without a hard cutover because Zig compiles C code through its own toolchain. It treats C not as a legacy to escape but as infrastructure to build upon.

What makes these three languages a coherent story rather than three independent projects is the pattern they share. In the old debate, the axis ran from programmer responsibility to runtime responsibility, with C at one end and Java at the other. The new languages break this axis by introducing new loci of responsibility and by refining the old ones to be less costly. Go accepted the runtime but made it fast enough and transparent enough that the trade-off became acceptable for a much wider range of problems. Rust rejected the runtime entirely and put the burden on the compiler, creating guarantees that neither C nor Java could offer. Zig rejected both the runtime and the compiler-as-enforcer, but built guardrails that eliminate the silent failures that made C dangerous while preserving the direct relationship between programmer and machine.

The reason all three are succeeding simultaneously is not that one found the right answer. It is that different problems demand different allocations of responsibility, and for the first time, we have languages that let us choose where the responsibility lives without also choosing a set of unrelated trade-offs. Go gives you runtime responsibility with minimal operational weight. Rust gives you compiler responsibility with no runtime cost. Zig gives you programmer responsibility with guardrails that catch mistakes before they become exploits. None is universally better. Each is better for a specific kind of person solving a specific kind of problem.

The practical question when choosing a language is not which language is best in the abstract. It is where you want the responsibility to live, and what you are willing to pay to put it there. The old debate — C versus Java, control versus safety, programmer versus runtime — was a product of an era with only two answers to the question of responsibility. We now have five: the programmer alone, the programmer with guardrails, the compiler, the light runtime, and the heavy runtime. Each has a language that embodies it. Each has a community that has proven it works at scale. The choice is not which answer is correct. The choice is which answer fits your problem and your temperament. For the first time, we have enough answers to ask the question properly.

← Prev Post Next Post →