Over the past 2 months or so, I have been trying to learn zig as a new systems programming language to try. I mainly got into it after I heard about the princples this language stands for which is simplicity, comptime for metaprogramming, and ofc the fact that it can be used as a c compiler which means that you can integrate c libraries with ease.
The last language I learnt before zig was Rust, which I believe is actually a great language with a lot of great ideas, and it got a lot of people into caring about writing performant software, but tbh it wasn't a language that I would enjoy write, and debug code in everyday, as it seems like a c++ successor than a c successor. which has a very big flaw, which is that a good tool should be easy to use, and learn. also I believe that there is only a small subset of language features that are really necessary to write good code. the rest are basically distractions, I want to think about the problem I'm working on and how to implement it in the most performant, safe, and intuitive to use solution to it, not about what is the best language feature to use here, or the best abstraction I can use to make the codebase full of indirection and very hard to navigate.
So, what makes Zig so great, besides its simplicity?
comptime
comptime is absolutely incredible. It is a massive upgrade over C/C++ macros and Rust's proc_macro. It is so intuitive and easy to use because it flows naturally with the rest of the program.
C Approach: Preprocessor Magic
#include <stdio.h>
#include <stdbool.h>
// The "Macro Template"
#define DECLARE_STACK(Type, Name, Capacity) \
typedef struct { \
Type data[Capacity]; \
size_t top; \
} Name; \
\
static inline void Name##_push(Name* s, Type item) { \
if (s->top < Capacity) { \
s->data[s->top++] = item; \
} \
} \
\
static inline Type Name##_pop(Name* s) { \
return s->data[--s->top]; \
}
// Generating a specific type: IntStack
DECLARE_STACK(int, IntStack, 10)
int main() {
IntStack stack = { .top = 0 };
IntStack_push(&stack, 42);
printf("%d\n", IntStack_pop(&stack));
return 0;
}
Rust Approach: Procedural Macros
extern crate proc_macro;
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Expr, Type, parse::Parse, parse::ParseStream, Token};
// A custom parser struct to handle: Stack!(i32, 10)
struct StackArgs {
ty: Type,
_comma: Token![,],
cap: Expr,
}
impl Parse for StackArgs {
fn parse(input: ParseStream) -> syn::Result<Self> {
Ok(StackArgs {
ty: input.parse()?,
_comma: input.parse()?,
cap: input.parse()?,
})
}
}
#[proc_macro]
pub fn make_stack(input: TokenStream) -> TokenStream {
let StackArgs { ty, cap, .. } = parse_macro_input!(input as StackArgs);
let expanded = quote! {
struct Stack {
data: [#ty; #cap],
top: usize,
}
impl Stack {
fn new() -> Self { Self { data: [0; #cap], top: 0 } } // Simplification
fn push(&mut self, item: #ty) {
if self.top < #cap {
self.data[self.top] = item;
self.top += 1;
}
}
fn pop(&mut self) -> #ty {
self.top -= 1;
self.data[self.top]
}
}
};
TokenStream::from(expanded)
}
// Generates the struct and implementation at compile time
make_stack!(i32, 10);
fn main() {
let mut s = Stack::new();
s.push(100);
println!("{}", s.pop());
}
Zig Approach: comptime
const std = @import("std");
// Just a normal function, but it returns a `type`
// and takes compile-time arguments.
fn Stack(comptime T: type, comptime capacity: usize) type {
return struct {
data: [capacity]T = undefined,
top: usize = 0,
const Self = @this();
pub fn push(self: *Self, item: T) void {
if (self.top < capacity) {
self.data[self.top] = item;
self.top += 1;
}
}
pub fn pop(self: *Self) T {
self.top -= 1;
return self.data[self.top];
}
};
}
pub fn main() !void {
// Instantiating the type naturally
var my_stack = Stack(i32, 10){};
my_stack.push(1337);
std.debug.print("{d}\n", .{my_stack.pop()});
}
If you have any taste for programming, you can clearly see that the comptime solution is miles ahead in terms of elegance, it's much more intuitive and easier to write and read.
How Zig Proved That Adding More Abstractions Isn't the Answer
It is fascinating to me how Rust and C++ tried to "fix" C by stacking layers of abstraction on top of it, only to end up being bloated, complicated, and a chore to read and write. Meanwhile, Zig took a different path: it removed some of the few implicit abstractions C actually has (like hidden memory allocations and magic I/O), giving us a language that is both incredibly simple and fun to work with.
By now, it's probably obvious that I'm a massive fan of having no hidden allocations, no hidden control flow, and no preprocessor magic. This philosophy makes your code predictable and debugging infinitely easier.
Explicit Memory Allocation
const std = @import("std");
pub fn main() !void {
// 1. Explicit Memory Strategy
// We explicitly choose a General Purpose Allocator. The runtime does not hide this.
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit(); // Enforce leak checking at scope exit
const allocator = gpa.allocator();
// 2. Explicit Dependency Injection
// The ArrayList cannot exist in a vacuum; it *must* hold a reference to our allocator.
var list = std.ArrayList(i32).init(allocator);
defer list.deinit(); // Explicit cleanup
// 3. Explicit Control Flow and Error Handling
// Appending can fail if the system runs out of memory.
// Zig forces you to acknowledge this with the `try` keyword.
// There are no invisible exceptions—it explicitly bubbles up the call stack.
try list.append(42);
try list.append(1337);
std.debug.print("Elements: {any}\n", .{list.items});
}
Defining your allocation strategy explicitly makes managing memory straightforward, and the defer keyword keeps cleanup simple and clean.
Functions in Structs: Zero cost syntactic-sugar.
I love this feature very much. I'm not a fan of OOP, actually I hate OOP it seems like a bad way to think about programming, first of all, as I said in the introduction, indirections and abstractions are bad, and object-oriented programming actually promote them, it's not just a bad way to organize code that makes the complicated and over-engineered. it also make the code very slow, as your class should save a vtable which is basically a lookup table that map your class method calls to their implementations, which actually enables polymorphism, also this whole everything is private by default, and this getter and setter pattern is basically useless and hurt your program performance.
To state all the flaws in the OOP Paradigm we will need a seperate post for it. so let's leave it alone for now, but actually there is one thing I really like about it, which is the method calling syntax. it's very elgant actually, many c programers will disagree, and they are not wrong to do so, as the differnce between the method call in object-oriented langauges and a good designed c api is very trivial. and it's not worthy of the cost, to put it into action see the difference between those two snipets of python and c.
class Circle:
def __init__(self, center: float, radius: float):
self.center = center
self.radius = radius
def calculate_area(self) -> float:
return 3.14 * (self.radius ** 2)
circle = Circle(0, 3)
area = circle.calculate_area()
typedef struct Circle {
float point;
float radius;
} circle_t;
float circle_calculate_area(circle_t *circle) {
return 3.14 * pow(circle->radius, 2);
}
int main() {
circle_t circle = { .point = 0, .radius = 3 };
float area = circle_calculate_area(&circle);
}
If you look at the difference you will find out that the main difference is in the . operator, this is a really great thing in object-oriented langauges, but it's very trivial thing to use OOP just for it, as it's basically garbage paradigm.
and here zig comes for the rescue, you actually don't have to choose between performance and neet interfaces, because in zig you will write it as follows.
const Circle = struct {
point: f32,
radius: f32,
pub fn init(point: f32, radius: f32) Circle {
return .{ .point = point, .radius = radius };
}
pub fn calculate_area(self: *Circle) f32 {
return 3.14 * self.radius ** 2
}
};
pub fn main() void {
const circle = Circle.init(0, 3);
const area: f32 = circle.calculate_area();
}
if you look at the zig version it's actually very similar to the python OOP version, but actually it have zero-cost, as it actually doesn't save a vtable, and if you were actually paying attention while reading, I said struct functions in the section title and not methods as it's not methods, you call it as methods, but it's actually static functions that we know at compile time or shall I say comptime, so we can basically use the clean OOP way of writing the api the developer interact with, but without the runtime overhead that comes with it.
Testing: A great approach to testing
one of the most important practices to create robust software is testing 1✕1and unfortunality one of the most overseen parts of it, and I have yet to found a language that makes writing test more pleasent than zig, one can argue that testing in rust and odin is a great experience, and I actually agree. writing tests in those two languages are actually great. but zig is in a completely other level, with the test keyword as a first-class citizen, you can simply write implementation for your function, then it's test underneath it directly in the same file easily and compile the tests when running zig build test, not just that, the zig standard library have great tools for testing, such as implementing an allocators, fuzzing tools for fuzz testing. 2✕2I think fuzzing can be a great use for modern ai agents. maybe I will run an experiment in the future and make a blog about it You also can search for specific tests into your codebase and implement it in isolation using its string identifier zig test file.zig --filter-test "specific test" command, here is an example of a simple test in zig so you can taste it for yourself.
const std = @import("std");
fn add(x: f32, y: f32) f32 {
return x + y;
}
test "adding two float numbers" {
const output: f32 = add(10, 10);
std.testing.expect(20, output);
}
it's a very trivial example that I felt embarrassed writting it and it actually reminded me of the typical youtube tutorials I used to see when I was first introduced to programming, where the tutor acutally write the most stupid and basic example possible to use the concept or design the thing and actually in most cases those examples are bad examples because those example are actually over-engineering the solution, like the above code, I mean wtf you would do with a test for a simple addition function 3✕3books are the best basically, although they maybe do the same thing.
Zig is Not Perfect
I've spent this entire post trying to convince you why Zig is great and why you should use it. But it is not perfect. Nothing is.
For one, I'm definitely not a Zig expert yet, so I'm still discovering its rough edges. But the most obvious challenge right now is that Zig has not reached v1.0.0. With every new release, parts of the standard library API are broken or deprecated. It's a backward-compatibility nightmare. You might pull in a third-party package only for it to fail to compile because the library was written for Zig v0.15.0 and you're using v0.16.0, I think the best way to solve this problem is to use the tigerbeetle approach and just add the compiler version you have as a part of your codebase and compile the project with this compiler 4✕4Actually I highly recommend you to read the Tigerstyle guideline as it has a great insight about building good software, not like the clean code book which is basically garbage., if you're into game dev, this is like vendoring your third party dependencies.
On top of that, the community is still relatively small. They are brilliant and incredibly helpful people, but it's a small crowd nonetheless.
Will I Use Zig for Large Projects?
Absolutely. I am currently building a robotics communication framework called glu from scratch. The goal is to make it a lightweight, high-performance alternative to ROS2—a framework that is widely disliked by indie developers and frustrating for labs that need maximum performance without the ROS2 bloat.
I'm writing glu entirely in Zig. Will the backward-compatibility breaks bite me in the ass? Probably. But the language is so good that it's a risk I'm more than willing to take. I want glu to be a rock-solid, reliable system for real production work.