Published 03/13/2023

Rust vs Zig

By Asher White

A photo of a rusty door with a zigzag pattern

Rust and Zig are two fairly new, low-level and very fast programming languages that fill a similar niche. How do they compare to each other? This blog post will examine the features, including memory access, performance and ease-of-use of both these languages and show which language is better adapted to which use cases. In general, Rust is a safer, more high-level language that is also much more mature as a language. Zig provides unparalleled flexibility over memory usage and allocations and its very close-to-the-metal approach could be valuable in some performance-sensitive areas.

Both Rust and Zig are intended as replacements for C/C++. C/C++ have been around for decades, but are still heavily used by performance-sensitive or low-level projects. While they do provide extremely fast speeds and a lot of flexibility, their downsides are becoming more and more apparent. The one you’ll hear most often is that C/C++ provide very little memory safety out-of-the-box—introducing hard-to-diagnose crashes and bugs. Other downsides of C and C++ are the lack of a standardized dependency management or build system, lack of high-level features that speed up development and confusing code modules. Both Rust and Zig were designed to fix these problems. They both feature more of an emphasis on memory safety (much more so in the case of Rust), a standardized, extensible build system, easy-to-use modules and, in the case of Rust, high-level features and a great package manager. At the same time, Rust and Zig provide very similar performance to C and C++ (sometimes faster), and similar levels of fine-grained control over hardware.

But, how do Rust and Zig compare to each other? One thing to keep in mind for the moment is that Rust is a mature and production ready language, but Zig hasn’t even released 1.0 yet. That means Rust has a lot of features that Zig might implement later on, but for the moment Rust has a clear advantage. In particular, Rust has an official package manager and an async/await system that work very well, but both those features are planned but not yet implemented for Zig. That said, Zig still has several very interesting features and can hold its own in a large project (like Bun). To compare the two languages, I wrote a JSON parser in each language, and I learned a lot about the languages along the way. First of all, what high-level features does each one have?

High-level features

High-level features include functional programming abilities, like .map and .for_each methods and object-orientated programming abilities too, like inheritance and generics. High-level features can also just be the functions in the standard library that take care of several steps at once instead of you having to spell it out.

Both these languages would be considered low-level, so neither have as many high-level features as, say, JavaScript or Python. But between Rust and Zig, Rust definitely has more high-level abilities. One of the things that you often hear about Rust is that it has zero-cost abstractions—meaning features that make the code easier to write without making it slower to run. This includes things like optimized iterators, macros and zero-sized types.

Zig, on the other hand, doesn’t have built-in iterators or inheritance. In fact, Zig advertises its lack of features as the first thing you see on its website: https://ziglang.org. Why is it advertised? The lack of high-level features absolutely makes the language easier to learn, and it also makes it easier to debug, and in some cases it makes code run faster as well. The downside is that it gets harder to write complex code, or code that would be simple in another language is complex in Zig.

One interesting feature that Zig has to compensate for the lack of features built into the language is compile-time execution. Rust and other languages have this too, but Rust requires writing a whole separate project and writing it as a macro. The upside of Rust’s approach is that a macro can modify the syntax before it is compiled, letting you introduce new forms of syntax and functions. The downside is the complexity—if you are making a library, you might want to write macros, but it often isn’t worth the extra effort for executables or smaller projects. Zig, on the other hand, doesn’t let you write macros that edit the syntax tree, but it lets you run code at compile-time very easily. You just add the comptime keyword to a variable, function or argument definition, and you’re good to go! Functions that run at compile-time let you avoid running the same code while the program is running, speeding it up, and they also let you work with types just like any you would with any other value (inspecting sizes and fields, creating new types, etc.).

What this is means is that although Zig doesn’t natively implement generics, inheritance or traits, these can all be provided by compile-time functions. Generics are the easiest: you can just accept a comptime type argument and then return a type. Here is an example from my JSON parser:

pub fn SliceIterator(comptime T: type) type {
    return struct {
        const Self = @This();
        ptr: [*]const T,
        len: usize,
    };
}

The code above is a function that takes a comptime parameter T with type type, and then it returns a type. For example, this could a u8 (byte), and the SliceIterator would iterate over a slice of u8’s. It’s a bit confusing coming from another language, but it makes sense once you realize that in Zig, everything is a value. Types are values. The only difference with type values is that they have to be known at compile-time. To take another example, to make a non-generic struct, you would write:

const StructType = struct {
    field: u8
    another_field: u16
}

A struct keyword does nothing on its own, it needs to be assigned to a name, just like a variable. However, I personally prefer Rust’s approach to structs and generics. This is how to write a functionally equivalent generic struct in Rust:

pub struct SliceIter<'a, T: Copy> {
    slice: &'a [T],
    index: usize,
}

This snippet shows Rust’s approach to generics. If a struct can contain generic fields, type parameters are added in angle brackets after the struct’s name (i.e., in SliceIter<'a, T: Copy>, T is a type parameter and T: Copy means that T has to be able to be copied). As you can see, in Rust, structs, even generic structs, are defined globally instead of being returned from functions, and there is a declarative syntax for requiring generic types to implement a specific feature (called a trait in Rust). In Zig, if you wanted to check if a generic type implemented a feature you would have to check manually using builtin functions.

This is just one example—across the board, Rust provides more high-level features, including more flexible loops, FP abilities, destructors and operator overloading, than Zig does. The tradeoff is that Rust is more complex to learn and sometimes more complex to maintain. That said, no one would choose to use Rust or Zig just for their high level features. They’re useful, but people really use these languages for other reasons, including more control over memory management.

Memory management

In contrast to most new languages, both Rust and Zig have manual memory management. That means they don’t have a garbage collector that runs and frees unused memory. The downside is increased complexity and sometimes unsafe, buggy code, but the upside is higher performance, lower overhead, and low-level control over memory. Between the two, Zig’s memory management is more like C’s, whereas Rust’s is quite unique.

Rust memory management is quite complex, and describing it in detail would take way more than one blog post. Briefly, it’s designed from the ground up to avoid memory-related bugs without using a garbage collector. It relies heavily on the concepts of ownership (where every piece of data has exactly one function scope that ‘owns’ it, the rest just ‘borrow’ it) and lifetimes (every value only lasts for the scope that owns it, and it can’t be used after that scope ends) to maintain memory safety across multiple functions and even multiple threads.

Zig’s memory management focuses more on configuration and flexibility. No heap allocations are done automatically—every function that makes a heap allocation takes an Allocator parameter to use, and nothing on the heap is freed either. Everything has to be done manually. While this does open up opportunities for bugs, it leads to unparalleled flexibility, more than Rust has.

Both languages have pointers, but Rust adds to this with ‘smart pointers’ that work the same way but guarantee that the memory is initialized. Getting values from ‘raw’ pointers in Rust requires the use of the unsafe keyword on the block or function, because a ‘raw’ pointer might not point to anything. Zig, on the other hand, only has pointers and slices (a pointer with a length).

An interesting feature of Zig is that it lets you change allocators for individual functions individually, just by passing an Allocator parameter. In Rust, you can change the global allocator easily, but changing allocators for individual functions is a work-in-progress that isn’t finalized yet (as of March 2023). That said, changing the global allocator is better supported in Rust than in Zig for the moment simply because fast allocators like jemalloc, mimalloc and snmalloc have Rust bindings but not Zig (for the moment). Another Zig feature is that every allocation has the possibility to fail and you can try to handle it in your code, but in Rust, allocations that fail crash the whole program. For 99% of programs, Rust’s approach works fine and is simpler, but for some systems, like embedded (or sometimes Windows…) it’s very valuable to have that kind of fine-grained control.

So, when you need absolute control and minimum heap allocations, Zig’s memory management workflow might be more useful. Otherwise, Rust’s is safer and easier to just ignore (but also more complex when you do need it). Low-level memory management is an important reason why someone would choose to use a low-level language. Another important reason is performance.

Performance

Comparing programming language performance is a tricky business and Rust vs. Zig is no exception. Both languages use LLVM backends, and in theory, Zig could be compiled to C and then the C to Rust, producing exactly equivalent machine code from both languages. So, for me, the most important performance metric is the one that’s hardest to measure—how fast is the code that the normal programmer writes? Because the highly optimized code will all be roughly the same speed in a low-level language, whether you’re using Zig, Rust, C, Fortran or whatever. If you want benchmarks for highly optimized code, you can look at them here, but more important is how fast normal code runs.

I have more experience with Rust than Zig, but from what I’ve seen of both, it’s easier to write fast code right off the bat in Rust, but if you put a medium amount of effort into optimizing it, Zig will be faster. And, if both are heavily optimized, they’ll be a very similar speed. That definitely wouldn’t apply in all cases however. One of Zig’s features is that there are no implicit function calls, so you’ll probably have fewer performance surprises (unless you use the GeneralPurposeAllocator, which is painfully slow). On a related note, compiled Zig is closer to the C ABI, so it easier to use with profilers (which helps its performance in a roundabout way).

Another big performance boost with Zig is how easy it is to use an arena allocator, if that applies to your use case. In Rust you have to use a crate with limited standard library support (bumpalo) but in Zig it’s built right into the standard library.

Rust, on the other hand, has more abstractions that end up producing the most optimized code. For example, iterators, closures and async/await functions. Because you have slightly less flexibility in safe Rust, it also makes it harder to write very slow code.

Interestingly, Zig has a powerful feature that isn’t stable yet in Rust—portable SIMD. SIMD is very valuable for processing large amounts of numbers, for example in audio/video and algebraic processing. However, each CPU architecture provides a different set of very low-level instructions, so normally you would have to either only use x86_64 (limiting what machines you can run your program on) or maintain two or more different implementations using different features. Portable SIMD solves this problems by providing a safe interface to the common features present in both x86 and aarch64 (and variations of those architectures). In Zig, portable SIMD is built right into the language, with a nice easy-to-use interface. Rust, on the other hand, only provided the architecture-specific intrinsics. Recently, however, nightly (unstable) Rust got portable SIMD added to the standard library, with a similar but safer interface than Zig’s. Rust’s interface feels more like functional programming, Zig’s approach feels a bit cobbled-together. Nonetheless, for the moment this is a big feature in Zig’s favour that makes it far easier to write SIMD code, but that advantage isn’t going to last long (there is no advantage if you’re okay using nightly Rust).

Both Rust and Zig are explicit about allocating memory on the heap, which can be expensive, which helps them to stay fast. They both have support for constants and compile-time code execution, but Zig is much more flexible in that regard, so if those important to your use case Zig would probably give better performance. Also, the most-optimized programs in Rust still have a slight overhead because of the safety checks the code has to perform to preserve memory safety, whereas Zig has two release modes: ReleaseSafe that has safety checks and ReleaseFast that skips them.

So, Zig and Rust and fairly evenly matched in terms of performance. For the JSON parser that I wrote, the results were very similar across both languages. Starting out, the benchmarks were more varied, but as the programs got more and more optimized the times dovetailed—the actual assembly for both Rust and Zig is done by LLVM anyway. If performance is a priority, neither language will let you down. But, which language is easier to write in?

Ease-of-use

This is easily the clearest category. Rust is complicated, Zig is simple.

I picked up Zig and was able to write a JSON parser twice as fast as the standard library one in less than a week. On the other hand, Rust took me months to learn thoroughly (it’s not an apples-to-apples comparison, knowing Rust helped me to learn Zig much faster, but it gives you an idea). Rust has a lot more ‘features’ than Zig does, especially for the moment, including object-oriented, functional and features, traits, attributes, macros and more. Additionally, Rust’s memory model takes some getting used to, with lifetimes and ownership, whereas Zig’s is much the same as C or whatever.

Is learning Rust worth it? Absolutely. All its features make it easier to write higher-level, more powerful code. For example, I would write a web server in either Rust or Zig, but I would never write a web service in Zig. It’s interface is just too low-level and I would be writing a lot of distracting boilerplate. On the other hand, Rust is a popular choice for very high-performance web services, with numerous frameworks that abstract away 99% of the complexity.

That’s another important point to consider. For the moment, there are far more frameworks and libraries for Rust than for Zig, and it will probably continue this way. Rust is more of a general-use language, Zig is more niche. Rust also has a working, easy-to-use package manager, whereas Zig’s official package manager is planned but not yet here. All these frameworks and libraries make it easier to code in Rust and keep you from wasting your time reinventing the wheel.

On the other hand, Zig’s simple, clean syntax makes it easy to pick up, and its close-to-the-metal approach might make it a good introduction with low-level programming. I would absolutely recommend learning Zig, even if you wouldn’t use it for 80% of your projects, if it would help speed up the other 20%. It‘s not as big an investment to learn Zig as it is to learn Rust.

In conclusion, both Zig and Rust are very powerful languages. Neither is strictly better, but Rust is more ergonomic with its high-level features, focus on memory safety and still high performance. On the other hand, Zig provides unparalleled control over memory usage, has a much simpler syntax and sometimes produces faster code. Both are useful to know—Rust is more useful for everyday use, but if you’re writing a OS kernel or a high-performance web server, you might like Zig. Both are flexible, fast and modern alternatives to C/C++ and are key players in their niche.

Ready to get started, or want some more information?

Whether you want a marketing website, a fullstack web app or anything in between, find out how we can help you.

Contact us for a free consultation

© 2024 Broch Web Solutions. All rights reserved.