Skip to content
Zig: Modern Systems Programming Without a Hidden Control Flow

Zig: Modern Systems Programming Without a Hidden Control Flow

DodaTech Updated Jun 20, 2026 7 min read

Zig is a modern systems programming language that prioritizes performance, safety, and simplicity. It has no hidden control flow — no garbage collector, no exceptions, no hidden memory allocations. What you see in the code is exactly what the CPU executes.

In this tutorial, you’ll learn Zig’s design philosophy, manual memory management, comptime (compile-time execution), cross-compilation out of the box, C interop without a separate build system, error handling with error unions, and the Zig build system.

What You’ll Learn

  • Zig’s goals and how it differs from C and Rust
  • No hidden allocations — explicit memory management
  • Comptime — run code at compile time
  • Cross-compilation — target any platform from any platform
  • C interop — import C headers directly
  • Error handling with error unions
  • The Zig build system (build.zig)

Why Zig Matters

Zig is the new generation of systems programming. It’s used in Bun (JavaScript runtime), TigerBeetle (financial database), and embedded systems. At DodaTech, Zig’s predictable performance and zero-overhead C interop make it ideal for Durga Antivirus Pro’s low-level file scanning layer.

Learning Path

    flowchart LR
  A[C/Rust Basics] --> B[Zig Basics<br/>You are here]
  B --> C[Comptime]
  C --> D[C Interop]
  D --> E[Cross-Compilation]
  style B fill:#f90,color:#fff
  

Zig Syntax

Zig syntax is familiar to C and Rust programmers:

const std = @import("std");

// Main function — return type is inferred
pub fn main() !void {
    // String (sentinel-terminated slice)
    const name = "Alice";
    const age: u32 = 30;

    // Print to stdout
    std.debug.print("Hello, {s}! You are {d} years old.\n", .{name, age});

    // Array
    const numbers = [_]u32{ 1, 2, 3, 4, 5 };

    // For loop
    for (numbers) |n| {
        if (n % 2 == 0) {
            std.debug.print("{d} is even\n", .{n});
        }
    }

    // While loop
    var i: u32 = 0;
    while (i < 3) : (i += 1) {
        std.debug.print("Count: {d}\n", .{i});
    }
}

Expected output: A greeting, even numbers from the array, then count 0-2.

Memory Management — No Hidden Allocations

Zig has no default allocator. Every allocation is explicit:

const std = @import("std");

pub fn main() !void {
    // Choose an allocator
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    const allocator = gpa.allocator();

    // Allocate an array — explicit allocation
    var list = try std.ArrayList(u32).initCapacity(allocator, 5);
    defer list.deinit();  // Explicit deallocation

    // Append values
    try list.append(10);
    try list.append(20);
    try list.append(30);

    // Print
    for (list.items) |item| {
        std.debug.print("{d}\n", .{item});
    }
    // 10
    // 20
    // 30
}

Expected behavior: The GeneralPurposeAllocator is created, used for all allocations, then deinitialized. No GC, no hidden mallocs. The defer keyword schedules cleanup when the scope exits.

Comptime — Compile-Time Execution

Zig can run arbitrary code at compile time:

const std = @import("std");

// Compile-time function
fn fibonacci(n: comptime_int) comptime_int {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

// Value computed at compile time
const fib10 = comptime fibonacci(10);

// Generic function — type parameter at compile time
fn max(comptime T: type, a: T, b: T) T {
    return if (a > b) a else b;
}

pub fn main() void {
    std.debug.print("Fibonacci(10) = {d}\n", .{fib10});
    // Fibonacci(10) = 55

    std.debug.print("Max(42, 17) = {d}\n", .{max(i32, 42, 17)});
    // Max(42, 17) = 42

    std.debug.print("Max(3.14, 2.71) = {d}\n", .{max(f64, 3.14, 2.71)});
    // Max(3.14, 2.71) = 3.14
}

Expected behavior: fibonacci(10) is evaluated at compile time. The max function is instantiated for both i32 and f64 types at compile time — zero runtime overhead.

Cross-Compilation

Zig cross-compiles to any target from any host, with no additional toolchains:

# Target ARM Linux from x86_64 macOS
zig build-exe -target aarch64-linux hello.zig

# Target WebAssembly
zig build-exe -target wasm32-freestanding hello.zig

# Target Windows from Linux
zig build-exe -target x86_64-windows hello.zig

# List all supported targets
zig targets

Zig ships with all the C headers and libraries needed for cross-compilation. No need to install separate toolchains.

C Interop

Import C headers directly — no bindings, no FFI:

// Call C standard library directly
const c = @cImport({
    @cInclude("stdio.h");
    @cInclude("math.h");
});

pub fn main() void {
    // Call printf
    _ = c.printf("Hello from C: sqrt(2) = %f\n", c.sqrt(2.0));

    // Call malloc/free
    const ptr = c.malloc(100);
    defer _ = c.free(ptr);

    c.printf("Allocated 100 bytes at %p\n", ptr);
}
// compile with: zig build-exe hello.zig -lc
// -lc links libc automatically

Expected behavior: Standard C functions are called directly. Zig handles the ABI, linking, and platform differences transparently.

Error Handling

Zig has no exceptions. Functions that can fail return an error union:

const std = @import("std");

// Error set definition
const FileError = error{
    NotFound,
    PermissionDenied,
};

// Function that can fail
fn readUserFile(path: []const u8) FileError![]const u8 {
    // Simulate file operation
    if (std.mem.eql(u8, path, "/etc/passwd")) {
        return "root:x:0:0:root:/root:/bin/bash\n";
    }
    return FileError.NotFound;
}

pub fn main() void {
    // Handle errors with catch
    const data = readUserFile("/etc/passwd") catch |err| {
        std.debug.print("Error: {}\n", .{err});
        return;
    };
    std.debug.print("{s}\n", .{data});

    // Or propagate with try (in a function returning error union)
    // const data2 = try readUserFile("/nonexistent");
}

Expected behavior: readUserFile returns an error union. The caller must handle errors with catch or propagate with try. Unhandled errors are a compile error.

The Build System

Zig projects use build.zig — a Zig file that configures the build:

// build.zig
const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    // Executable
    const exe = b.addExecutable(.{
        .name = "myapp",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });
    exe.linkLibC();  // Link libc

    // Install
    b.installArtifact(exe);

    // Run command
    const run_cmd = b.addRunArtifact(exe);
    b.step("run", "Run the app").dependOn(&run_cmd.step);

    // Tests
    const tests = b.addTest(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });
    b.step("test", "Run tests").dependOn(&tests.step);
}
zig build        # Build
zig build run    # Build and run
zig build test   # Run tests

Common Mistakes

1. Forgetting to Handle Errors

Zig doesn’t let you ignore errors accidentally. If a function returns an error union, you must handle it.

2. Not Using defer for Cleanup

Manual memory management means every allocation needs a matching free. Use defer to pair them.

3. Confusing const with Runtime Values

const in Zig means const in the mathematical sense — it binds a name to a value. It doesn’t guarantee compile-time evaluation (that’s comptime).

4. Ignoring Sentinel-Terminated Arrays

Zig strings are not null-terminated by default. When calling C functions, use @ptrCast or explicitly null-terminate.

5. Not Using var for Mutable Pointers

const pointer means you can’t modify through it. Use var for mutable pointers.

6. Overusing anyopaque

anyopaque is Zig’s void*. Use typed pointers instead of casting through anyopaque.

Practice Questions

1. What makes Zig different from C?

No hidden control flow (no GC, no exceptions), comptime execution, built-in cross-compilation, and a modern build system.

2. What is comptime?

Code execution at compile time. Functions marked comptime run during compilation, enabling generics and compile-time computation.

3. How does Zig handle errors?

With error unions (ErrorType!T). Functions return either a success value of type T or an error. Callers must handle errors explicitly.

4. How do you cross-compile in Zig?

Pass -target <triple> to any Zig command. Zig includes all needed headers — no separate toolchain installation.

5. Challenge: Build a comptime JSON parser.

Write a comptime function that parses a JSON string at compile time and returns a Zig struct.

Mini Project: File Copy Tool

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    if (args.len != 3) {
        std.debug.print("Usage: {s} <source> <dest>\n", .{args[0]});
        return;
    }

    const source = try std.fs.cwd().readFileAlloc(allocator, args[1], 10 * 1024 * 1024);
    defer allocator.free(source);

    try std.fs.cwd().writeFile(.{ .sub_path = args[2], .data = source });
    std.debug.print("Copied {d} bytes\n", .{source.len});
}

FAQ

Is Zig a replacement for C?
Yes — Zig is designed as a modernization of C. It compiles to identical machine code but adds safety features, comptime, and a better build system.
Does Zig have a package manager?
Zig 0.12+ has a built-in package manager. Packages are defined in build.zig.zon and fetched automatically by zig build.
Is Zig production-ready?
Zig is pre-1.0 but is used in production by Bun, TigerBeetle, and embedded systems. The language is stable, and the standard library is mature.

What’s Next

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro