Skip to main content
Technology & EngineeringSystems Programming237 lines

Zig Basics

Zig language fundamentals for systems programming including comptime, error handling, and manual memory management

Quick Summary28 lines
You are an expert in the Zig programming language for writing systems-level code.

## Key Points

- Always use `defer` and `errdefer` for resource cleanup immediately after acquisition.
- Pass allocators explicitly rather than relying on global state.
- Use `comptime` to move work from runtime to compile time when inputs are known.
- Prefer slices (`[]T`) over raw pointers (`[*]T`) for bounds-checked access.
- Use `std.testing` and `zig test` for unit tests colocated with source code.
- Leverage `@import("builtin")` to query target architecture for cross-compilation.
- Use `std.log` with scoped loggers instead of `std.debug.print` in library code.
- Forgetting `defer` for cleanup leads to resource leaks; always pair acquisition with deferred release.
- Returning a pointer to stack memory produces a dangling pointer; allocate on the heap or return by value.
- Ignoring error returns silently discards failures; use `try`, `catch`, or explicitly discard with `_ = expr`.
- Mixing up `[]T` (slice) and `[*]T` (many-pointer) causes subtle bugs at C boundaries.
- Using the wrong allocator lifetime (e.g., arena freed too early) leads to use-after-free.

## Quick Example

```zig
fn process() !void {
    const data = try readFile("config.txt");
    defer allocator.free(data);
    try parseConfig(data);
}
```
skilldb get systems-programming-skills/Zig BasicsFull skill: 237 lines
Paste into your CLAUDE.md or agent config

Zig Basics — Systems Programming

You are an expert in the Zig programming language for writing systems-level code.

Core Philosophy

Overview

Zig is a systems programming language designed as a practical alternative to C, offering manual memory management, comptime metaprogramming, and first-class interop with C/C++ libraries. It targets the same use cases as C but with improved safety, readability, and maintainability. Zig has no hidden control flow, no hidden allocations, and no garbage collector.

Core Concepts

Comptime (Compile-Time Evaluation)

Zig's comptime keyword forces expressions to be evaluated at compile time, replacing the need for macros or template metaprogramming.

fn fibonacci(comptime n: u32) u32 {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

// Evaluated entirely at compile time
const fib_10 = fibonacci(10); // 55

Comptime also enables generic programming:

fn List(comptime T: type) type {
    return struct {
        items: []T,
        len: usize,

        const Self = @This();

        pub fn get(self: Self, index: usize) T {
            return self.items[index];
        }
    };
}

const IntList = List(i32);

Error Handling

Zig uses error unions instead of exceptions. Errors are values that can be returned from any function.

const FileError = error{
    NotFound,
    PermissionDenied,
    IoError,
};

fn readFile(path: []const u8) FileError![]u8 {
    const file = std.fs.cwd().openFile(path, .{}) catch |err| {
        return switch (err) {
            error.FileNotFound => FileError.NotFound,
            error.AccessDenied => FileError.PermissionDenied,
            else => FileError.IoError,
        };
    };
    defer file.close();

    return file.readToEndAlloc(allocator, max_size) catch FileError.IoError;
}

The try keyword propagates errors concisely:

fn process() !void {
    const data = try readFile("config.txt");
    defer allocator.free(data);
    try parseConfig(data);
}

Manual Memory Management with Allocators

Zig does not have a default allocator. Every allocation is explicit and tied to an allocator interface.

const std = @import("std");

pub fn main() !void {
    // General purpose allocator with safety checks in debug mode
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    var list = std.ArrayList(u8).init(allocator);
    defer list.deinit();

    try list.appendSlice("hello");
}

Arena allocator for batch allocations:

var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();

// All allocations freed at once when arena is deinitialized
const buf1 = try allocator.alloc(u8, 1024);
const buf2 = try allocator.alloc(u8, 2048);
// No need to free individually

Slices and Pointers

// Slices are a pointer + length pair
fn sum(items: []const i32) i64 {
    var total: i64 = 0;
    for (items) |item| {
        total += item;
    }
    return total;
}

// Sentinel-terminated pointers for C interop
const c_string: [*:0]const u8 = "hello";

Packed Structs and Bit Manipulation

const Flags = packed struct {
    readable: bool,
    writable: bool,
    executable: bool,
    _padding: u5 = 0,
};

const flags = Flags{ .readable = true, .writable = false, .executable = true };
const raw: u8 = @bitCast(flags); // 0b00000101

Implementation Patterns

Tagged Unions for State Machines

const Token = union(enum) {
    identifier: []const u8,
    number: i64,
    plus,
    minus,
    eof,

    pub fn format(self: Token, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void {
        switch (self) {
            .identifier => |name| try writer.print("ident({s})", .{name}),
            .number => |n| try writer.print("num({})", .{n}),
            .plus => try writer.writeAll("+"),
            .minus => try writer.writeAll("-"),
            .eof => try writer.writeAll("EOF"),
        }
    }
};

Defer and Errdefer for Resource Cleanup

fn connectAndQuery(url: []const u8) !Result {
    const conn = try openConnection(url);
    errdefer conn.close(); // Only runs if function returns an error

    const stmt = try conn.prepare("SELECT * FROM users");
    errdefer stmt.deinit();

    const result = try stmt.execute();
    // On success, caller owns conn and result
    return result;
}

Async I/O (Event Loop)

const std = @import("std");

fn fetchUrl(url: []const u8) ![]u8 {
    var client = std.http.Client{ .allocator = allocator };
    defer client.deinit();

    var req = try client.request(.GET, try std.Uri.parse(url), .{}, .{});
    defer req.deinit();

    try req.start();
    try req.wait();

    return try req.reader().readAllAlloc(allocator, 1 << 20);
}

Best Practices

  • Always use defer and errdefer for resource cleanup immediately after acquisition.
  • Pass allocators explicitly rather than relying on global state.
  • Use comptime to move work from runtime to compile time when inputs are known.
  • Prefer slices ([]T) over raw pointers ([*]T) for bounds-checked access.
  • Use std.testing and zig test for unit tests colocated with source code.
  • Leverage @import("builtin") to query target architecture for cross-compilation.
  • Use std.log with scoped loggers instead of std.debug.print in library code.

Common Pitfalls

  • Forgetting defer for cleanup leads to resource leaks; always pair acquisition with deferred release.
  • Returning a pointer to stack memory produces a dangling pointer; allocate on the heap or return by value.
  • Ignoring error returns silently discards failures; use try, catch, or explicitly discard with _ = expr.
  • Mixing up []T (slice) and [*]T (many-pointer) causes subtle bugs at C boundaries.
  • Using the wrong allocator lifetime (e.g., arena freed too early) leads to use-after-free.
  • Comptime functions that accidentally depend on runtime values cause confusing compile errors.

Anti-Patterns

Over-engineering for hypothetical scale. Building for millions of users when you have hundreds adds complexity without value. Solve today's problems first.

Ignoring the existing ecosystem. Reinventing functionality that mature libraries already provide well wastes time and introduces unnecessary risk.

Premature abstraction. Creating elaborate frameworks and utilities before you have enough concrete cases to know what the abstraction should look like produces the wrong abstraction.

Neglecting error handling at boundaries. Internal code can trust its inputs, but system boundaries (user input, APIs, file I/O) require defensive validation.

Skipping documentation for obvious code. What is obvious to you today will not be obvious to your colleague next month or to you next year.

Install this skill directly: skilldb add systems-programming-skills

Get CLI access →