Why Everyone Talks About Zig

What's All the Hype About?

 · 19 min
IBM System/360 promotional photo (~1965)

There’s a new contender in the ever-evolving landscape of programming languages everyone seems to talk about: Zig.

It’s making waves, especially for systems programming, with its unique blend of performance, safety, and simplicity. Designed as a modern alternative to C and C++, it offers seamless C interoperability and precise memory control while remaining accessible to newcomers. This makes it a robust alternative, even for Rust or Go.

Let’s take a closer look!

Be aware that I’m not a systems programming expert!
I love what Rust does, and I dabbled with it in the past and read a few books.
Golang is my “go-to solution” (pun intended) for smaller tools that exceed the scope of shell scripting, and I wrote multiple tools with it.

This article reflects my opinion “from the outside” as a developer living mostly in managed languages like Java and Swift.


A Brief History of Zig

Zig was created by Andrew Kelly and had its first public release in 2016.

Zero, the Ziguana (CC BY 4.0 The Zig Foundation)
Zero, the Ziguana (CC BY 4.0 The Zig Foundation)
Compared to other systems languages, it’s a few years ahead of other similarly targeted languages. For example, Rust first appeared in 2012, and Golang in 2009.

Despite its youth and strong competition, Zig has quickly carved out a niche for itself. Currently, the two most well-known projects using Zig are the JavaScript runtime/all-in-one tool Bun and the recently released cross-platform terminal emulator Ghostty.

Zig has become a language worth watching in just a few years. It appeals to anyone who values control, safety, and simplicity in their software development.

Fun fact: The name “Zig” appears to be chosen by a script using random 4-letter words starting with “Z”. I couldn’t find any information on how it became a 3-letter word, though. Most likely, something like “Zigg” caught the creator’s attention, and they didn’t want a repeated character.


What Is Zig?

At its core, Zig is a general-purpose language for systems programming.

The language was born from a desire to make a more pragmatic one as C. Its design philosophy focuses on providing developers with a balance between performance, safety and robustness, and simplicity and ease of use:

  • Pragmatism:
    Its developer-centric simplicity, lack of unnecessary complexity, and minimalistic syntax keep it approachable for newcomers and experienced developers alike.

  • Optimality:
    Top-of-the-line runtime performance by writing the most “natural” and expressible code.

  • Robustness:
    Optimality is number one, but safety is a close second, without sacrificing high efficiency. Developers have full control over performance-critical features like manual memory management.

  • Transparency:
    Zig promotes explicitness in its code, avoiding hidden control flows that obscure program behavior. For instance, no hidden allocations exist as all memory management is explicit or no implicit type casting.

There are a few standout features, like its fantastic C-interop and comptime, which we will see shortly. Other expected features from a modern language include integrated tooling and easy cross-compilation.

Here’s the obligatory “Hello, World”:

zig
const std = @import("std");

pub fn main() void {
    const lang = "Zig";
    std.debug.print("Hello, {s} World!\n", .{lang});
}

Even without knowing the finer details of the language, the syntax feels familiar if you worked with a C-like/-inspired language before.


Core Features

Let’s discuss the core aspects of the language in more detail to paint a more detailed picture of the advantages the Zig provides.

No Hidden Control Flow

Having no hidden control flow is a fundamental principle of Zig.

There are many features in other languages that make code behavior unpredictable or obscure the actual flow of data, making it harder to reason with:

  • Implicit Type Conversion/Casting:
    Automatic conversion between data types can lead to unintended behavior, like lost precision, conversion errors, or overflows, which are a no-go. Only safe and unambiguous type coercions are allowed.

  • Operator Overloading:
    Overloading standard operators might be an alluring feature that provides a lot of functionality. Still, it changes default behavior and might lead to unexpected function calls or side effects.

  • Exceptions and Hidden Error Handling:
    Exceptions are a powerful tool, but they obscure control flow, as functions might exit prematurely without being explicitly obvious in the code.

  • Automatic Memory Management:
    Garbage collection and “hidden” allocations are a safety feature, offloading complexity to the runtime. However, it also introduces unpredictability and performance costs.

  • Implicit Async:
    Features like async/await can conceal the actual flow of execution, making it hard to reason about the sequence of operations, especially when debugging.

As you might have guessed, Zig addresses these concerns by having no hidden control flow.

No Implicit Type Conversion/Casting

Casting converts a value to another type. Many languages auto-cast specific types like primitives implicitly, but not Zig.

Instead, it uses type coercion if a conversion is known to be safe and unambiguous, like for constant values:

Types must be explicitly converted:

zig
const std = @import("std");

pub fn main() void {
    const a: f32 = 23.0;
    const b: i32 = 42;

    // SAFE COERCION WITHOUT PRECISION LOSS
    const sum: u8 = a + b;

    std.debug.print("Sum: {}\n", .{sum});

    const x: f32 = 23.5;
    const y: i32 = 42;

    // UNSAFE DUE TO PRECISION LOSS
    const unsafe: u8 = x + y;

    std.debug.print("Sum: {}\n", .{unsafe});
}

// COMPILER ERROR
// src/main.zig:16:26: error: fractional component prevents float value '65.5' from coercion to type 'u8'
//     const unsafe: u8 = x + y;
//                        ~~^~~

Explicit casting is included via built-in functions, like @intFromFloat, in case a safe type coercion isn’t possible.

No Operator Overloading

In another step towards transparency and apparent control flow, there’s no operator overloading.

I’m a little torn on this one, as overloading operators can create interesting DSLs, which I discussed in a previous article about Swift. But I totally understand where the omission is coming from, as it obscures code flow and requires knowing how different types interact via the overloaded operators.

Clear Error Handling

Exceptions are avoided; Errors are part of the function’s type signature. They’re handled through error unions and the try and catch keywords, making error handling an explicit part of the logic.

Errors in Zig are returned as part of a special union type, denoted by a ! (exclamation mark) prefix. This type explicitly states that a function may either return a successful value or an error:

zig
// DEFINE AVAILABLE ERRORS
const Errors = error {
    DivideByZero,
    // ...
};

fn divide(a: f32, b: f32) !f32 {
    if (b == 0) {
        return Errors.DivideByZero;
    }
    return a / b;
}

There’s now throw involved, like in Java or Swift, that circumvents normal control flow and prematurely exits the current function. And unlike Golang, no tuple is required either.

Instead, either an error or a valid value is returned by the same keyword: return.

As error handling is explicit, there are two options to deal with it:

  • Call divide with try, requiring the surrounding function to add the error to its signature
  • Handle the error with catch

The first option acknowledges the error and enforces handling the error by a caller up the stack:

zig
const std = @import("std");

pub fn main() void {

    // USE `try` TO PROPAGATE ERROR UPWARDS THE STACK
    const result = try divide(23.0, 42.0);

    std.debug.print("Result: {}\n", .{result});
}

First, this code does not compile:

src/main.zig:6:20: error: expected type 'void', found '@typeInfo(@typeInfo(@TypeOf(main.divide)).@"fn".return_type.?).error_union.error_set'
    const result = try divide(23.0, 42.0);
                   ^~~~~~~~~~~~~~~~~~~~~~
src/main.zig:3:15: note: function cannot return an error
pub fn main() void {

Even the main function must declare the possible error with a !void return type if it doesn’t handle them. The only problem is that there’s nothing above main, so our application would crash!

So, let’s deal with the error instead:

zig
const std = @import("std");

pub fn main() void {

    // USE `catch` TO HANDLE THE ERROR
    const result = divide(23.0, 0) catch |err| {
        std.debug.print("Caught an error: {}\n", .{err});
        return;
    };

    std.debug.print("Result: {}\n", .{result});
}

As the error union is handled directly, no try is needed; therefore, !void is no longer, as no possible error is propagated anymore.

The catch keyword either returns the valid result, or makes the error available in the handler block via the |<variable name>| assignment. The handler block must then return a value according to the signature, in this case nothing, as it is void.

switch is also possible to handle multiple errors more elegantly, like for an HTTP handler:

zig
self.handle(action, req, res) catch |err| switch (err) {
  HttpError.NotFound => {
    res.status = 404;
    res.body = "Resource Not Found";
    res.write() catch return false;
  },
  else => self.handleOtherErrors(req, res, err),
}

Zig’s approach to error handling fits nicely into its overall goals:

  • Transparency:
    Make all possible error paths explicit in the unction type signatures.

  • Predictability:
    No hidden control flow; every potential error must be handled explicitly.

  • Safety:
    Encourages thorough error handling, reducing the risk of unhandled errors that can crash the program.

Zig’s error union types may initially feel weird for developers coming from languages with “traditional” exceptions, like myself. However, their verbosity and enforcement lead to greater clarity, enabling more robust, maintainable, and highly reliable systems.

Manual Memory Management

Zig provides complete control over heap memory allocation and deallocation to ensure that memory is managed efficiently and predictably.

Unlike C and C++, Zig introduces allocators, which are explicitly passed to functions to avoid hidden memory operations. Allocations are paired with the defer pattern to ensure memory cleanup:

zig
const std = @import("std");

pub fn main() !void {
    // OBTAIN ALLOCATOR
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    // PASS ALLACOTOR AND DEFER TO ENSURE CLEANUP
    const array = try createData(allocator, 10);
    defer allocator.free(array);

    // PRINT DATA
    for (0.., array) |idx, value| {
        std.debug.print("arry[{d}]={d}\n", .{ idx, value });
    }
}

fn createData(allocator: std.mem.Allocator, count: usize) ![]u32 {
    // ALLOCATE ENOUGH MEMORY WITH THE GIVEN ALLOCATOR
    var array = try allocator.alloc(u32, count);

    // INITIALIZE SOME DATA
    for (0.., array) |idx, _| {
        array[idx] = @intCast(idx * idx);
    }

    return array;
}

Passing allocators is a key principle in the stdlib that you should absolutely adopt for your code.

But why pass an allocator in the first place instead of creating one if needed?

For the same reasons for most things in Zig:

  • Transparency:
    No hidden allocation within functions makes resource use more efficient, predictable, and easier to reason about.

  • Flexibility:
    The caller can choose the best allocator fitting their use case. There are multiple allocators available, and you could even create your own.

By prioritizing explicit memory management, Zig eliminates many common pitfalls of hidden memory operations found in other not-as-strict languages. That might be tedious at first, but it gives total control and flexibility.

Optional Standard Library

Another standout feature making Zig an excellent choice for embedded or resource-constrained environments is that the standard library is entirely optional!

Unlike most modern languages, you’re not forced to use stdlib if you don’t want to, so it won’t be included in the binary.

And the optional parts don’t end there, as libc is another optional piece of the Zig puzzle. This is desirable for environments where it’s unavailable or undesirable, such as low-level system kernels, custom runtime environments, or bare-metal binaries.

This is another great example of Zig’s explicit approach to everything. There are no hidden dependencies, not even the stdlib, as the code that’s explicitly used is included in the final binary and nothing more.

That’s a big advantage over languages like Go or Rust, whose standard libraries are tightly integrated and often include features you don’t use or want, leading to larger binaries by default.

Zig puts fine-grained control of what to include in the developer’s hands, making it a flexible but conscious decision.

That lean approach also helps Zig to target WebAssembly with small and fast-loading modules.

Integrated Build System

Tooling around a language makes and breaks the fun and productivity we enjoy.

For instance, JavaScript’s quagmire of constantly evolving/changing/breaking tools or Java’s often frustrating Gradle experience can be a bane for these languages.

Many newer languages try to tame this chaos by including their own tooling and dependency management to provide a default go-to to make the overall experience more seamless.

Rust has cargo, Go has its modules/formatting/testing built into the go command, and even modern approaches to JavaScript/TypeScript like Deno deliver all the tools necessary to handle a project’s immediate needs. So any modern language needs to provide something that makes it easier for developers, and Zig doesn’t disappoint.

Zig has a built-in package manager and build system to streamline dependency management and cross-platform builds. These tools are tightly integrated into the language, offering first-class support for building, testing, and compilation.

Build scripts are written in Zig itself, in a file called build.zig:

zig
const std = @import("std");

pub fn build(b: *std.Build) void {
    const exe = b.addExecutable(.{
        .name = "my-app",
        .root_source_file = b.path("src/main.zig"),
        .target = b.host,
    });

    b.installArtifact(exe);
}

Having the tooling use the programming language itself is a great boon, as developers only need to learn one language to do both. Also, it provides strong-typed and easily verified build scripts with the help of the compiler.

You don’t even need a build script and can build or run code directly: zig build-exe src/main.zig and zig run src/main.zig

Cross-compilation can also be done directly, as the target architecture and operation can be specified directly:

zig build-exe --target x86_64-linux-gnu src/main.zig

Overall, the build system is a complex beast, as all build systems are, but we can start with little effort from the beginning. Its documentation is separate from the language reference.

Seamless C-Interop

C-interoperability is a must-have as a systems programming language, and most languages support it to different degrees. Where Zig stands out against the crop is how seamless it is.

Using C libraries is as simple as importing the header; not additional bindings are needed:

zig
const stdio = @cImport({
    @cInclude("stdio.h");
});

pub fn main() void {
    // RETURN VALUE MUST BE ACKNOWLEDGED
    _ = stdio.printf("Hello from C and Zig!\n");
}

libc or an equivalent must be linked during the build process so the header can be found.

C-interoperability goes both ways, as it’s super easy to generate functions callable from C by using the export keyword:

zig
export fn add(a: i32, b: i32) i32 {
    return a + b;
}

// IN C:
// extern int add(int x, int y);

The seamless C-interop makes Zig an excellent tool for working and modernizing C projects, either by using them from C or writing newer code in Zig and consuming it from C:

  • No additional tools or bindings required
  • Zig handles the complexities of linking, symbol resolution and type conversion automatically
  • Easily replace parts of large C projects incrementally with safer, more maintainable , and predictable Zig code

Comptime

We’ve seen many great and standout features, but comptime is a powerful and unique feature. It enables code to be executed at compile time.

Unlike most languages that separate compilation and runtime logic quite strictly, Zig has a two-tier compilation process that allows the generation of data, performs calculations, or even conditionally includes code.

This approach shifts computation from runtime to compilation, reducing runtime overhead and creating greater flexibility in program design.

First, let’s create a lookup table:

zig
const std = @import("std");

// Generate a compile-time lookup table.
const table = comptime generateTable();

fn generateTable() []u8 {
    return [_]u8{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
}

pub fn main() void {
    std.debug.print("Table at compile time: {any}\n", .{table});
}

The result is baked into the library, removing any runtime computation.

comptime isn’t restricted to simple things; anything goes where all variables are known, like storing the result of a recursive factorial calculation:

zig
const std = @import("std");

fn factorial(n: u32) u32 {
    return if (n == 0) 1 else n * factorial(n - 1);
}

pub fn main() void {
    const result = comptime factorial(10);
    std.debug.print("Factorial: {d}\n", .{result});
}

Adding something like std.time.sleep will fail, as the comptime function is no longer deterministic at compile time, and the compiler tells us where and why:

/home/ben/.zig/lib/std/os/linux.zig:1528:9: error: unable to evaluate comptime expression
        @intFromPtr(request),
        ^~~~~~~~~~~~~~~~~~~~
/home/ben/.zig/lib/std/os/linux.zig:1528:21: note: operation is runtime due to this operand
        @intFromPtr(request),
                    ^~~~~~~
/home/ben/.zig/lib/std/Thread.zig:80:55: note: called at comptime from here
            switch (linux.E.init(linux.clock_nanosleep(.MONOTONIC, .{ .ABSTIME = false }, &req, &rem))) {
                                 ~~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

src/main.zig:4:19: note: called at comptime from here
    std.time.sleep(@as(u64, 5) * std.time.ns_per_s); // safe, but requires explicit coersion
    ~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
src/main.zig:10:38: note: called at comptime from here
    const result = comptime factorial(10);
                            ~~~~~~~~~^~~~
src/main.zig:10:20: note: 'comptime' keyword forces comptime evaluation
    const result = comptime factorial(10);
                   ^~~~~~~~~~~~~~~~~~~~~~
// ...SNIP...

Another use case for comptime is conditional code branches:

zig
const std = @import("std");
const builtin = @import("builtin");

pub fn main() void {
    if (comptime builtin.os.tag == .macos) {
        // This code will only be included if the target OS is macOS.
        return;
    }

    std.debug.print("I'm not on macOS, that's for sure\n", .{});
}

This optimizes binaries, as the code needed for computations, or unused conditional code branches aren’t included anymore after compilation. And, of course, there’s no longer runtime computation required.

Other languages do this, too, either with text-based macros or multiple files. But including it directly into any file is a nice touch.


Zig Vs Rust Vs Go

Zig, Rust, and Go are all modern programming languages designed to address the limitations of previous tools like C/C++ and their shortcomings.

However, Zig stands out in several ways compared to others, as it goes new or at least interesting new ways.

Here’s how it compares in key areas:

Performance

Zig emphasizes explicit manual memory management with total control over allocation and deallocation. The lack of garbage collection ensures predictable and low-latency performance. The ability to exclude the standard library and even libc makes it ideal for minimal, performance-critical applications, especially in resource-constrained environments.

Rust provides high performance through zero-cost abstractions and fine-grained memory safety thanks to the borrow checker. Its memory model eliminates undefined behavior without sacrificing runtime performance, making it a strong choice for safety-critical systems while still being a performant contender. However, the borrow-checker is a complex beast with the steepest learning curve among the three.

Go trades some of its raw performance for simplicity by being a garbage-collected language. Handling memory automatically can be a great boon, especially if we don’t need absolute performance. Still, it introduces unpredictable latency, making it less suitable for real-time or performance-critical systems than the other two.

Safety

Zig promotes safety through explicit control over error handling and memory management. No hidden controls flows are memory allocations. Many implicit features are missing in action. Nothing happens without us knowing about or making the decision to do it.

Rust is known for its strict memory safety guarantees, thanks to ownership and borrowing rules. This way, the borrow checker and compiler prevent many issues, such as data races, use-after-free, and buffer overflows, at compile time. However, these safety features come with quite a lot of added complexity and can be intimidating to newcomers.

Go primary focus is simplicity. It offers basic memory safety with garbage collection. Compared to the others, though, it can still lead to runtime panics and overlooking issues more easily.

Simplicity

Zig prioritizes minimalistic design, avoiding many complex features like macros, async/await, or operator overloading. The syntax is clean and approachable, making it easy to read and maintain without sacrificing raw power. That makes it a good choice for both beginners and experts.

Rust offers a rich feature set, including lifetimes, traits, etc., making it a “powerful but complex” language. The borrow-checker and advanced abstractions create a steep learning curve, especially for newcomers.

Go is designed for simplicity and ease of use, focusing on developer productivity.That came at the cost of lacking advanced features like manual memory management, macros, or stronger safety guarantees. Still, it’s the most approachable language of the bunch. But this makes it less versatile for low-level programming.


Why Zig?

So, why should you choose Zig for your next project?

Well, Zig stands out in the modern programming landscape by balancing explicitness, simplicity, and the row power needed for systems programming. Its fundamental philosophy of being as transparent and predictable as possible gives the developer complete control over their code while avoiding undefined behavior, hidden control flow, or implicit runtime implications.

Zig’s versatility appeals to developers of each of the common systems programming languages:

  • C developers can escape the hazards of undefined behavior while maintaining full control. Even an incremental approach is possible.

  • Rust devs tired of fighting the borrow-checker, looking for a more reasonable learning curve while still wielding maximum power and safety.

  • Go developers looking for control over memory management and reduced runtime costs for performance-critical apps.

In a world where performance, safety, and simplicity are often a “choose 2” decision, Zig strikes an impressive balance.

But I also need to talk about the reasons NOT to choose Zig.

Why Not Zig

The most obvious one is its maturity. Even though there are awesome projects like Ghostty or Bun out there, Zig still hasn’t released 1.0, and even kind of recommends using the nightly build. That means big API changes and incompatibilities between versions, like projects bound to specific version.

For example, I couldn’t build Ghostty from source, as it required a lower version (0.13) than the available nightly at the time (0.14).

The next thing that didn’t work was to build the LSP, which first complained about the wrong nightly version and then, after updating the compiler, threw errors during compilation.

Things like that are to be expected before an “official” 1.0 release. Still, that makes it a hard sell to businesses and decision-makers who don’t have dedicated resources to adapt regularly or become stuck on a particular version.

Rust, on the other hand, released its 1.0 a decade ago.

The next problem is linked to Zig’s youth, too: the ecosystem;

Every language needs to build up its available tools and libraries over time, and a lot is happening and growing steadily! The seamless C-interop also bridges a lot of gaps you might encounter. However, we must admit that Rust and Golang have a larger ecosystem, which is particularly relevant in a business environment, with a bigger talent pool available.

Another aspect of Zig that’s “great (terms and conditions may apply)” is its strive for simplicity. Every missing complexity for simplicity reasons leaves out features we might take for granted in other languages.

For instance, the lack of a built-in concurrency model compared to Go’s goroutines or Rust’s fearless concurrency might be a dealbreaker for you.

We can still implement much of the “missing” functionality ourselves, and the ecosystem will provide many things over time. However, a project involving heavy parallelism or concurrency might make things more complex than needed, and we might choose a different language instead.

Still, you should definitely give Zig a try!

Why Zig, Even If You Don’t Use It Professionally

Learning new programming languages, even without directly using them in a professional setting, is IMHO a worthwhile endeavor.

Not only does every new language expose us to unique design philosophies, paradigms, and approaches to solving problems, but it also offers a fresh perspective that challenges and expands our thinking.

We gain insights into techniques and concepts that may not be prevalent in our primary language. But a broader understanding helps us to write better code in the long run and not get set in our ways.


Resources

Zig Projects