Jj
May 14, 2025 • 7 min read

For about a year now, I've been using Zig. My primary background is in C++, so it wasn't incredibly difficult to adapt to the way Zig does things. Additionally, there are some excellent resources for Zig online - from the source code, to the Zig guide, to passionate articles by community members.
I've reached the point where, if a program suits C, I'll happily write it in Zig instead. There are some scenarios where this isn't viable, namely embedded devices that aren't too Zig-friendly, or codebases that need to be maintained by other people. However, for personal projects, I'll happily choose Zig.
I'll explain why this is the case, by directly pinning practical examples of C's downfalls to Zig's superior design decisions. Do note - I say superior with a charitable outlook on C, considering it's conception was in the 1970s. A comparable language built in the 2000s should always be superior in design, unless the language developer has some major lack of foresight.
💡
NB: All C compilation in the examples below is done with GCC, all Zig compilation is done with Zig 0.14.
Undefined Behavior
An always hot topic with C - undefined behavior. In a large number of scenarios, it's easy to perform some action which is clearly unwanted, leading to a crash, subtle or un-subtle bug, or even just an incorrect piece of data spawning within the program. Take a signed integer overflow:
int main() { int x = INT_MAX; x += 1; printf("%d\n", x); return 0; }This code prints -2147483648, which is drastically far from my intended output - for obvious reasons. In Zig, this kind of behavior is caught at runtime with an explicit error:
pub fn main() void { var x: i32 = std.math.maxInt(i32); x += 1; std.debug.print("{}", .{x}); }This leads to a very loud error: thread 9352 panic: integer overflow.
💡
NB: You can manually opt-in to the C-like UB behavior with modifiers in-code in Zig.
So, more protections in Zig, but not in the horribly annoying, overbearing-compiler way which languages like Rust opt for.
Error Handling
Precise and descriptive errors make a programmer's life easier, objectively. C programmers typically rely on errno type errors, perhaps getting it's related error message through perror.
int main() { FILE *file = NULL; errno_t err = fopen_s(&file, "data.txt", "r"); if (err != 0 || file == NULL) { perror("Failed to open file"); return 1; } char buffer[100]; if (fgets(buffer, sizeof(buffer), file) == NULL) { perror("Failed to read from file"); fclose(file); return 1; } printf("Read: %s\n", buffer); fclose(file); return 0; }This code attempts to read a file, error checking each step of the way. Return values can easily be ignored due to the lack of enforcing of error checking, or just as easily forgotten. Even using the "safer", modern fopen_s - a lot of overhead goes in to manually opening, checking, and closing each step of the way. In this case, the error I receive when data.txt doesn't exist is a one-liner; Failed to open file: No such file or directory. Not very descriptive, and would warrant a visit with the debugger in a larger program.
Now, the Zig alternative:
pub fn main() !void { const stdout = std.io.getStdOut().writer(); var file = try std.fs.cwd().openFile("data.txt", .{}); defer file.close(); var buf: [100]u8 = undefined; const read_bytes = try file.readAll(&buf); try stdout.print("Read: {s}\n", .{buf[0..read_bytes]}); }Concise code, enforcing error checking with the try keyword. the defer keyword closes the file at the end of the current scope, meaning I don't have to worry about manually closing the file wherever it may be warranted. Zig forces adherence to strict checking, without making it a pain to deal with. Extending / modifying the functionality of the error checking is simple, too - just add the catch keyword and some custom behavior. Additionally, the error message is considerably more descriptive:
error: FileNotFound C:\Tools\zig\lib\std\os\windows.zig:126:39: 0x7ff66565a34c in OpenFile (zig.exe.obj) .OBJECT_NAME_NOT_FOUND => return error.FileNotFound, ^ C:\Tools\zig\lib\std\fs\Dir.zig:927:19: 0x7ff6656416bd in openFileW (zig.exe.obj) .handle = try w.OpenFile(sub_path_w, .{ ^ C:\Tools\zig\lib\std\fs\Dir.zig:803:9: 0x7ff665627796 in openFile (zig.exe.obj) return self.openFileW(path_w.span(), flags); ^ C:\Users\Jj\Documents\code\test\zig\src\main.zig:5:16: 0x7ff66568e7bb in main (zig.exe.obj) var file = try std.fs.cwd().openFile("data.txt", .{}); ^A precise trace with filename, line number, and even helpful pointers to the exact place of error. Much more helpful! Not to mention the ability to easily create your own error unions that seamlessly integrate with the enforced error checking system.
Build System
C is notorious for arguments around build systems. CMake, Makefile, Bazel, Premake, Build.bat - the list goes on. Instead of learning an entirely new programming language just to build your code - you could write your build script in the same language as the one you're building your codebase with. This is where the Zig build system shines. Whether it's building a pure Zig codebase, using C / C++ ABI, or cross-compiling - you have the tools at your fingertips with little to no extra effort. Take the build script I've been using to run these examples:
pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); const exe = b.addExecutable(.{ .name = "zig", .root_source_file = .{ .cwd_relative = "src/main.zig" }, .target = target, .optimize = optimize, }); b.installArtifact(exe); const run_cmd = b.addRunArtifact(exe); run_cmd.step.dependOn(b.getInstallStep()); if (b.args) |args| { run_cmd.addArgs(args); } const run_step = b.step("run", "Run the app"); run_step.dependOn(&run_cmd.step); }Simple, understandable to anyone who knows Zig syntax, and extendable.
Preprocessor Macros vs Comptime
Zig does away with the preprocessor macro style of programming, replacing it with a powerful comptime system. For those unaware, preprocessor macros in C tend to just be a basic string replace that gets acted upon before compilation. You might use this to set up a conditional debug log:
#if DEBUG #define LOG(msg, ...) printf("[DEBUG] " msg "\n", ##__VA_ARGS__) #else #define LOG(msg, ...) #endif int main() { LOG("Starting up with value = %d", 42); return 0; }The first thing you may notice is that it looks rather cryptic. It adds some level of clutter to the top of a file, and is quite easy to break - it involves no type checking, no awareness of scope - subtle bugs are common. Zig, on the other hand, uses type checked, scope-aware language semantics to build just as powerful patterns as macros.
fn log(comptime msg: []const u8, args: anytype) void { if (enable_log) std.debug.print("[DEBUG] " ++ msg ++ "\n", args); } pub fn main() void { log("Starting up with value = {}", .{42}); }Similarly to C macros, further care can be taken to ensure comptime blocks are completely eliminated outside of debug builds, meaning there's no performance overhead to using them. Generally a safer, clearer practice. Rather than being a dumb string replace, this is a fully baked, fully considered language feature in Zig, meaning it'll likely only get better while macros stay archaic.
NB: Each of the above examples prints the exact same [DEBUG] Starting up with value = 42
Memory Management
Memory management in C is a highly controversial subject. For the sake of this blog post's brevity, I will assume we're all happy with the arena allocator as the primary method of memory management. While setting up an arena in C is infinitely better than spewing malloc and free all over a program, it isn't without its own flaws. Hand rolling a truly extensive arena is a lot of work. That is, when considering bounds checking, composability, thread safety, management of lifetimes and so on. A basic arena in C might look like:
typedef struct { uint8_t *buffer; size_t size; size_t offset; } Arena; Arena arena_create(size_t size) { Arena arena; arena.buffer = malloc(size); arena.size = size; arena.offset = 0; return arena; } void *arena_alloc(Arena *arena, size_t size) { if (arena->offset + size > arena->size) return NULL; void *ptr = arena->buffer + arena->offset; arena->offset += size; return ptr; } void arena_reset(Arena *arena) { arena->offset = 0; } void arena_destroy(Arena *arena) { free(arena->buffer); } int main() { Arena arena = arena_create(1024); int *a = arena_alloc(&arena, sizeof(int)); *a = 42; printf("a = %d\n", *a); arena_destroy(&arena); return 0; }This prints a = 42. While this is an elegant memory management solution for C, showing the Zig equivalent is likely better than trying to explain it's benefits:
pub fn main() !void { const gpa = std.heap.page_allocator; var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const allocator = arena.allocator(); const a = try allocator.create(i32); a.* = 42; std.debug.print("a = {}\n", .{a.*}); }Beautiful. No black boxes, type safe, bounds checked, concise, explicit about the use of a page allocator, error checked, composable with Zig allocator APIs, and cleaned up with defer. While not unattainable in C, this cleans things up a lot, leaving less room for programmer error. It's also worth mentioning that Zig offers many types of allocators - so if you do want to stray from the arena, it's as simple as swapping out the chosen API call.
💡
Zig still isn't memory safe, and you do get full control over memory. It doesn't have garbage collection or anything similar to Rust's borrow checker - which I consider a good thing for my manual programming style. YMMV.
Generics
Zig's powerful comptime functionality can also be used with native generics. To achieve this in C, you need to play with pointers and memory in an unsightly way. To take a less complex example: a swap function in C:
void swap(void *a, void *b, size_t size) { unsigned char tmp[size]; memcpy(tmp, a, size); memcpy(a, b, size); memcpy(b, tmp, size); } int main() { int x = 1, y = 2; swap(&x, &y, sizeof(int)); printf("x = %d, y = %d\n", x, y); return 0; }We make clever use of void* and size_t to mimic the shape of a generic. However, this method is flawed - the compiler is in the dark about what's being swapped, so passing any incorrect type or size will lead to completely undetected UB. Whether that manifests as a brutal crash or subtle bug is up to the compiler and your own luck. Revisiting Zig's comptime, we can make a much safer implementation of the swap function:
pub fn swap(comptime T: type, a: *T, b: *T) void { const tmp = a.*; a.* = b.*; b.* = tmp; } pub fn main() void { var x: i32 = 1; var y: i32 = 2; swap(i32, &x, &y); @import("std").debug.print("x = {}, y = {}\n", .{ x, y }); }Here, we see type-safe native generics (comptime T), optimization for size, and error checking for incorrect types. A zero cost abstraction that won't introduce UB into your program.
Conclusion
Truly, I could go on. Zig has seamless and extensive cross compilation, granular and explicit integer types, a built-in unit testing framework, and many more individual design decisions which tend to blow C out of the water. I look forward to what 1.0 brings, and once the breaking language changes stop, Zig will be a no-brainer for all of my future low-level projects.