Why I fell in love with Zig
Over the last couple of weeks, I set out to learn Zig. I went into it assuming it would be just another bloated piece of software weighed down by too many layers of abstraction—the kind that makes a language more complex instead of making development easier.
The last language that gave me that headache was Rust. Don't get me wrong, Rust's ownership model for managing memory is clever. But for me, it's just not worth dealing with all the unnecessary complexity Rust forces on you while you work. To put it simply: Rust is just not fun to write or read.
Some people will claim Rust is more fun than a language like Go because Go is "too repetitive", or something like that, and my only response to that is: "go work on real problems, bro." as the fun, creative thinking should be spent solving the actual problem at hand—not fighting the language's syntax or trying to figure out which hyper-abstract concept you need to implement basic logic.
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.
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.
Explicit I/O Subsystem
const std = @import("std");
pub fn main() !void {
// 1. Explicitly acquire a handle to Standard Output
// This doesn't happen automatically; you must ask the OS subsystem for it.
const stdout_file = std.io.getStdOut();
// 2. Initialize a buffered Writer stream
// Zig separates the raw file descriptor from the stream interface.
// If you want buffering to prevent frequent system calls, you wrap it explicitly.
var bw = std.io.bufferedWriter(stdout_file.writer());
const stdout = bw.writer();
// 3. Perform the I/O operation
// Because I/O can always fail (e.g., broken pipe, disk full),
// the compiler forces you to handle the error with 'try'.
try stdout.print("Hello, {s}!\n", .{"systems engineering"});
// 4. Explicitly flush the buffer to the OS
// With buffered I/O, you control exactly when the syscall happens.
try bw.flush();
}
The code above is essentially how you write a production-grade printf in Zig. You might look at this and think it's tedious boilerplate. You might even be right. But I think it's beautiful.
What it actually does is force you to initialize your I/O devices with explicit implementation details. You get absolute control over your code. If you think that's "too much control," then go back to doing web development at some bloated startup, selling over-engineered solutions to non-existent problems using 100K lines of boilerplate for basic CRUD apps.
How std.io Actually Works Under the Hood (Peak Comptime)
If you look at how other languages handle I/O, they usually rely on virtual tables (vtables) or interface types for dynamic dispatch. Rust has std::io::Write trait objects; Go has io.Writer interfaces. This works, but it adds runtime overhead.
Zig doesn't do dynamic dispatch here. Instead, it uses comptime duck typing to construct writers and readers on the fly.
In the standard library, std.io.Writer is just a function that returns a type:
pub fn Writer(
comptime Context: type,
comptime Error: type,
comptime writeFn: fn (context: Context, bytes: []const u8) Error!usize,
) type {
return struct {
context: Context,
pub const ErrorSet = Error;
const Self = @this();
pub fn write(self: Self, bytes: []const u8) Error!usize {
return writeFn(self.context, bytes);
}
// ... provides helper methods like print(), writeAll(), writeByte() at compile-time
};
}
This is insanely powerful. Any struct that implements a write function can be instantly wrapped into a full-featured Writer at compile time. No vtables, no interfaces, and zero virtual call overhead. The compiler resolves it all down to direct function calls.
For a project like glu—where I'm building a lightweight robotics communication framework to replace the bloated monster that is ROS2—this is a lifesaver. When publishing telemetry or serializing command payloads over TCP/UDP sockets or serial lines, I can wrap raw handles in custom writers without paying any abstraction tax. Every single byte goes exactly where it needs to, precisely when it needs to, with absolute predictability.
Build Systems: What Got Me Into Zig in the First Place
This is the main reason I got into Zig. I love C, but its build system options are enough to drive anyone crazy. You are stuck wrestling with CMake or Makefiles, and you have to manually install, build, and link your third-party dependencies. (Though to be honest, a big reason I got decent at programming was because I used to build C projects with zero external dependencies out of sheer laziness to avoid the linking nightmare).
But when you want to build actual, real-world systems, you need a solid build system. Zig gives you that—and it doubles as an amazing build system for C and C++ projects too.
While I haven't used the full C/C++ build system integration yet, importing libraries like cuda and vulkan into Zig was incredibly smooth. And for my latest project, glu, exporting my Zig API functions and types back into C has been a flawless experience.
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.
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.
A Final Note on AI and Zig
Because Zig is a younger language and still changes rapidly, LLMs struggle with it. LLMs are essentially advanced copycats, and since there aren't millions of Zig repositories to copy from yet, they often write broken code.
But if you want to learn a systems language like Zig, you should have the passion to write most of the heavy-lifting logic yourself anyway. Use the AI to spit out the boring boilerplate, and write the real code on your own.