Zig is a general-purpose systems programming language designed by Andrew Kelley. The language bills itself as “a general-purpose programming language and toolchain for maintaining robust, optimal, and reusable software”.

Proponents of Zig often argue that the language is a revolutionary low-level language that represents a massive shift in how you think about programming. They claim it’s the best C replacement language. This is high praise indeed, so let’s see if it can actually live up to the hyperbole.

Philosophy

Its core philosophy emphasises explicit control: the language motto is “No hidden control flow”. In practice, this means all memory allocation, error handling, and control-flow constructs must be spelled out by the programmer.

Kelley has stressed that the language is for creating “optimal” solutions at the cost of some programmer inconvenience. Correctness and robustness are equally held as important goals for Zig code.

Critics argue that Zig adding undefined behaviour compared to C for additional possible compiler optimisation benefits runs counter to these last two goals, although the Zig documentation claims they are not in opposition:

“Zig uses undefined behavior as a razor sharp tool for both bug prevention and performance enhancement.” link

At the same time, even Zig’s docs acknowledge that both ReleaseFast and ReleaseSmall have no protection against UB that isn’t detected when testing with Debug or ReleaseSafe.

From this, we can conclude that the Zig approach is to assume that all undefined behaviour will be caught while testing in safe mode, and the application can then safely be run without checks and with full performance in production.

This is different from most other languages that either retain many checks, or at least try to retain well-defined behaviour as much as possible.

Doubtful claims of excellence

Zig made an early splash with the claim that “Zig is faster than C” in early talks by Kelley. This turned out not to be quite true. The benchmarks turned out to be meaningless, as the Zig code had been compiled to the native architecture, while the C code was compiled for a generic CPU. When this discrepancy was removed, they compiled to the same code.

The docs still talk about Zig being faster than C, and in the community, many assume it’s true, even though the docs do mention “native arch” by default:

“For native targets, advanced CPU features are enabled (-march=native), thanks to the fact that Cross-compiling is a first-class use case.” link

Here I am not sure why “cross-compiling” being a first-class use case has to do with compiling for the native CPU. In practice, accidentally compiling for native architecture and then being unable to share the executable happens to many beginners.

The other big claim, which caused conflict with the Rust community, is the claim of safety without trade-offs, or as the Zig docs say: “Performance and Safety: Choose Two”.

As we already saw, Zig doesn’t really acknowledge that there is a problem running with UB in production as long as it has been tested with debug mode on. This should rightly be criticised. Anyone with experience of projects that have gone through lengthy testing can testify: bugs still happen in production. Even in full debug mode, Zig only checks UB and not overall correctness – for that, something like contracts are needed, and Zig does not have them.

Another claim was Zig’s “colourblind” async, which “solved the async colouring problem”. However, it was later removed from the language, and it’s currently waiting for problems with the implementation and semantics to be worked out.

Async is interesting in other ways, as it seems to grossly violate the “explicitness” principle of Zig, but was nonetheless added.

First impressions

The first Hello World looks like this:

const std = @import("std");

pub fn main() void {
    std.debug.print("Hello, world!\n", .{});
}

We start by defining the constant std, which gives us an alias to the struct/module (Zig doesn’t differentiate between the two) containing the standard library.

Second, we print using the standard “debug” print, and Zig’s explicitness is already on full display: it uses full paths as recommended – although we could possibly have aliased it somewhat further – and there is that little .{} at the end.

This .{} is because Zig lacks vaargs or default arguments, so .{}, which is an empty anonymous struct in Zig, has to be passed even though we don’t really have any argument to format.

Already here we see a vast rift between Zig and Odin, which we previously looked at. In fact, at this point, it is similarly verbose to the infamous Java hello world of System.out.println("Hello, World").

If we want to do anything more complicated, we need to use a build.zig script. There is a way to use Zig’s package manager, but to do that we must dig deep into how Zig build and “zon” files work. Once this is done (in my particular case, I copied a project that already had a build.zig with related files for raylib development).

After a lot of fiddling and consulting the docs (as the errors are not helpful), you might discover that this is the

var pos: ray.Vector2 = .{ .x = 640, .y = 320 };

In case someone wonders: no, var pos: ray.Vector2 = .{ 640, 320 }; doesn’t work.

Of course, if you don’t use pos, the compiler will say that this is an error and refuse to compile as well. And if you discard that error (typically using _ = pos;), the compiler will complain that it should be a const.

Not warnings here, compilation errors.

Converting the example from the Odin article, we get this:

const std = @import("std");
const rl = @import("raylib.zig").raylib;

pub fn main() void {
    rl.InitWindow(1280, 768, "Testing");
    var pos: rl.Vector2 = .{ .x = 640, .y = 320 };
    while (!rl.WindowShouldClose()) {
        rl.BeginDrawing();
        rl.ClearBackground(rl.BLUE);
        rl.DrawRectangleV(pos, .{.x = 32, .y = 32}, rl.GREEN);
        if (rl.IsKeyDown(rl.KEY_LEFT)) {
           pos.x -= 400 * rl.GetFrameTime();
        }
        if (rl.IsKeyDown(rl.KEY_RIGHT)) {
           pos.x += 400 * rl.GetFrameTime();
        }
        if (rl.IsKeyDown(rl.KEY_UP)) {
           pos.y -= 400 * rl.GetFrameTime();
        }
        if (rl.IsKeyDown(rl.KEY_DOWN)) {
           pos.y += 400 * rl.GetFrameTime();
        }
        rl.EndDrawing();
    }
    rl.CloseWindow();
}

Zig can’t compete with Odin’s bundled “vendor”, of course, but the whole process of using build.zig is so much work up front trying to figure out how the Zig build system works – a daunting task if you’re still learning the language, whereas for Odin it “just works”.

The mandatory names in the initialisers (.x etc.) feel just superfluous and unergonomic for things like vectors. Despite looking more like C than Odin, Zig keeps having its own way of doing things, violating expectations.

Error handling

Zig’s error handling is fairly novel: it returns a kind of “Result” type that needs to be immediately handled. Zig errors can also optionally be implicitly returned or invoke a panic:

const std = @import("std");

pub fn main() !void {
    const file = std.fs.cwd().openFile("foo.txt", .{}) catch |err| label: {
        std.debug.print("unable to open file: {}\n", .{err});
        return;
    };
    // "try" rethrows the error, similar to Odin's "or_return"
    // const file = try std.fs.cwd().openFile("foo.txt", .{});
    // Alternatively panic:
    // const file = std.fs.cwd().openFile("foo.txt", .{}) catch unreachable;
    defer file.close();
    try file.writeAll("test");
}

Unlike exceptions or even Odin’s approach, there is no simple way to handle all errors in a single sweep, which means that usually Zig code will use try and handle all results at a higher level. We could continue diving into error handling, but for a supposedly simple language, Zig has a lot of new features that need covering.

Zig comptime

According to many Zig proponents, the poster child for Zig’s versatility and simplicity is its compile-time execution. As we’ll see, Zig’s compile time is more of a cross between a toned-down Jai meta-programming and C++ templates.

Zig’s compile-time execution serves three main goals:

  1. To enable polymorphic functions.
  2. To generate generic types.
  3. To conditionally compile code.

The primary mechanism comes from Zig taking untyped or compile-time-only arguments. Compile-time-only arguments are things like types, which only have representation at compile-time. Untyped arguments allow compile-time “duck typing”.

Unfortunately, the “duck typing” suffers the same problem as C++ templates – that instantiating them creates an error that possibly is shown rather deep into the generic library code.

The generic types that Zig can create are more flexible than C++ templates, though, being able to implement things as SoA (struct of array) types at compile-time. The cost of this is that an IDE that wishes to provide checking on generic Zig types would need to execute the entire generic function in order to understand its actual layout. It is clear from this that, like Jai, Zig does not prioritise IDE friendliness.

This complexity is also something a human reader will have to deal with, and the comptime here is quite implicit in what it does. This implicitness is even worse when conditionally compiling code.

The following code will compile:

const foo : bool = false;
if (foo) {
    const x : i32 = 1.2;
    _ = x;
}

But setting foo to true instead will reveal a compile-time error as the implicit conversion from 1.2 to i32 isn’t allowed. Similarly, a function that isn’t called will not even be type-checked. This allows the somewhat humorous situation where you can write a function and marvel that it compiles in Zig, just to notice that you forgot to call it or declare it public and therefore it’s not actually checked.

While not checking conditionally evaluated code is quite reasonable, the highly implicit way Zig implements it is rather questionable, to say the least.

There is no reason why an ordinary if should lazily evaluate its branches. The language could have used a construct like inline if (which it uses for comptime versions of for loops) to make it explicit, but has chosen to make it more implicit.

While Zig’s comptime undoubtedly makes the language more powerful and unifies three different concepts – generics, compile-time evaluation, and polymorphism – it comes at a clear lack of clarity and explicitness.

It is unclear how this matches the language motto of “no hidden control flow”.

Verbosity

Critics say that Zig is verbose. Is there some merit to this? We already mentioned the long paths (std.debug.print) in passing. Another commonly raised problem is the casts. Zig has tens of different cast operations as Zig “builtins” (special functions that provide functionality that otherwise would not be available) to specify exactly from what category we’re converting to and from (e.g. @intFromFloat), which causes math-heavy code to balloon.

One user submitted the following on Reddit:

fn fib(n: u8) u32 {
    const sqrt_5: f32 = comptime std.math.sqrt(5.0);
    const golden_ratio: f32 = (1.0 + sqrt_5) / 2.0;

    const n_f32: f32 = @floatFromInt(n);
    const result_f32 = @round(std.math.pow(f32, golden_ratio, n_f32) / sqrt_5);

    return @intFromFloat(result_f32);
}

Proponents argue that this makes code “honest” and predictable.

Another complaint has been the added noise due to the syntax changes from C, such as replacing for loops with Zig’s while:

var block_h: u32 = 0;
while (block_h < block_cnt_h) : (block_h += 1) {
    var block_w: u32 = 0;
    while (block_w < block_cnt_w) : (block_w += 1) {
        // Loop body
    }
}

This translates to the following in C:

for (unsigned block_h = 0; block_h < block_cnt_h; block_h++ ) {
    for (unsigned block_w = 0; block_w < block_cnt_w; block_w++ ) {
        // Loop body
    }
}

Although the Zig community, on the whole, seems happy with Zig’s syntax despite this, it’s hard to know if this is confirmation bias (i.e. people who don’t like the syntax do not use Zig) or if it’s just something that grows on you.

A common refrain on forums is that Zig’s learning curve is steep: it has “strong opinions” about syntax and requires learning many new idioms. As one commenter on Hacker News warns, if a developer does not “appreciate simplicity and control over tiny details,” Zig “will not bring anything particularly interesting” link

The road to Zig 1.0?

Another concern for Zig to tackle is the apparently ever-increasing scope of the project. Zig has soon been in development for ten years, with no clear end in sight. Because Zig is funded by donations, this is not the death knell it would be for a commercial project. However, it remains a long-term concern if the language cannot reach version 1.0. It should be noted that ten years is a fairly common timeframe for a language to reach 1.0, and Odin – about a year “younger” than Zig – is clearly much closer to the elusive milestone.

It is clearly not a lack of funding or contributors: Zig has enough of both. The problem appears more like classic feature creep. The Zig compiler has now been rewritten more than once; it is also aiming to replace LLVM with its own backends, its own linker, and so on. The list seems endless.

Even if these components are not strictly required for 1.0, they all have to be coordinated, leaving less time for the language itself.

Zig: the good parts

After all this doom and gloom, let’s focus on the area where Zig is already king: cross-platform compilation.

Ironically, cross-platform compilation is already available out of the box with LLVM, but Clang – which builds on LLVM – makes it non-trivial to cross-compile from start to finish.

While solving this problem for Zig, the team realised that with their bundled Clang library, they could offer a better frontend to Clang, providing cross-compilation out of the box.

This has been enormously successful, with major companies adopting “zig cc” (Zig’s Clang frontend) as their preferred compiler for cross-compilation. This cements the Zig compiler as a worthwhile product – even though, ironically, it has little to do with the language itself.

With Zig now present in toolchains, there have been pushes to get companies using build.zig to script their builds, as a way of selling the language itself. While this has seen some success, it hasn’t led to official adoption by any major company.

While Zig users swear by build.zig’s elegance, it’s not universally loved.

Summary

Zig aims to be a modern C replacement with a focus on explicit control, safety, and zero hidden behaviour – but it often trades usability for perceived ideological purity. Features like comptime offer powerful metaprogramming capabilities, but they make standalone tooling and IDE support significantly harder. While it is touted as being safer and faster than C without compromises, it’s doubtful that the faster than C claim holds up under close scrutiny. It’s also worth noting that Zig usually disables safety checks in release builds.

Despite nearly a decade of development, Zig 1.0 still feels far off. Ironically, its biggest success isn’t the language itself but its excellent cross-compilation tooling – so much so that many use zig cc as a drop-in Clang replacement without writing a single line of Zig.

But is this really the best alternative to C? Odin offers more high-level conveniences, while Zig is far more bare-bones. At times, it seems as though within the Zig community, “low-level” and “high performance” are conflated with “no abstractions”, as if something as thin and anæmic as the C standard library were a necessary condition for speed. In several ways, Zig is actually more painful to work with than the C it aims to replace. The argument that Zig is a great C alternative seems to have little to support it.

Aside from first mover advantage, there seems to be little to recommend Zig over Odin or other C alternatives, but as we all know, it’s not always the best alternative that wins.