First Impressions: Zig for Systems Programming

What it feels like to drop into Zig after years of C and Rust — the good, the sharp edges, and the comptime magic.

Background

I came to Zig from a mix of C (day-to-day embedded work) and Rust (side projects, CLI tools). Zig occupies an interesting middle ground: it aims to be a better C without the complexity of Rust’s borrow checker.

What I like immediately

Comptime

comptime is Zig’s answer to C templates, Rust generics, and macro systems — but without a separate metalanguage. Any expression you can write at runtime, you can also evaluate at compile time:

main.zigzig
const std = @import("std");

fn maxOf(comptime T: type, a: T, b: T) T {
    return if (a > b) a else b;
}

pub fn main() void {
    const x = maxOf(i32, 10, 42);
    std.debug.print("{d}\n", .{x});
}

This eliminates a whole class of C macro abuse while staying readable.

Explicit allocators

Every allocation is explicit. There is no hidden heap; you pass an allocator to anything that needs memory:

zig
const allocator = std.heap.page_allocator;
const list = try std.ArrayList(u8).init(allocator);
defer list.deinit();

Compared to Go (implicit GC) or C (implicit malloc), this is more verbose — but you always know where memory comes from.

Error handling

Zig uses error unions instead of exceptions or errno:

zig
fn readFile(path: []const u8) ![]u8 {
    const file = try std.fs.cwd().openFile(path, .{});
    defer file.close();
    return try file.readToEndAlloc(std.heap.page_allocator, 1 << 20);
}

The ! prefix means “this returns T or an error.” try unwraps or propagates. It reads almost as naturally as Go’s if err != nil, but the type system enforces it.

The sharp edges

  • No closures — this trips me up coming from Go/Rust.
  • undefined behaviour — less than C, but still present in release builds.
  • Young tooling — the LSP (ZLS) is great but occasionally loses type inference on complex comptime paths.
  • Package manager is new (Zig 0.12+) — ecosystem is still growing.

vs. C

Zig can call C directly (@cImport, @cInclude) with zero overhead. For replacing individual C files in a mixed project, this is compelling.

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

pub fn main() void {
    _ = c.printf("hello from C via Zig\n");
}

Verdict

Zig is not ready to replace Rust for safety-critical work, and not as mature as C for legacy embedded targets. But for new systems code where you want C-level control with better ergonomics and a sane build system, it’s worth the investment.

I’ll be writing more as I build a small bytecode VM in Zig — stay tuned.

By PatrickChoDev

You might also like