Languages No. 124

Zig Programming skill for Claude Code

Zig for your pinned compiler, not last year's stdlib. Claude checks zig version before writing anything, because each Zig release rewrites parts of the standard library. It then passes allocators and the 0.16 Io interface explicitly, pairs every allocation with defer or errdefer, handles error sets exhaustively, keeps comptime generics simple, and wires build.zig modules, zig fetch dependencies and C libraries correctly.

  • namezig-programming
  • size277 lines · ≈2,850 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Writes Zig that matches the installed compiler version, with explicit allocator passing (DebugAllocator, arenas, std.testing.allocator), error unions with try, errdefer and exhaustive catch switches, comptime generics kept simple, build.zig modules, dependencies via zig fetch, C interop through translate-c and addCSourceFiles, and the std.Io interface introduced in 0.16. Use when writing or reviewing Zig, porting code between Zig releases, fixing leaks or panics, setting up build.zig or build.zig.zon, cross-compiling with zig cc, or wrapping a C library.

Try asking

  • Port this Zig 0.13 project to 0.16 — it fails on std.io and ArrayList everywhere
  • std.testing.allocator says this parser leaks; find the missing errdefer
  • Wrap libsqlite3 in a small Zig module with error unions instead of return codes

Use it when

  • Writing or reviewing Zig code or build.zig files
  • Code written for one Zig release fails to compile on another
  • Tests report leaks or Debug builds panic on overflow or unreachable
  • Wrapping a C library or cross-compiling with zig cc

Skip it when

How to check the result

zig build test --summary all passes on the version in build.zig.zon, std.testing.allocator reports no leaks, and the tests also pass with -Doptimize=ReleaseSafe.

The common mistake it prevents

Writing Zig from memory of an older release, so std.io, ArrayList, allocator names and build API calls fail to compile on the project's actual compiler.

~/.claude/skills/zig-programming/SKILL.md

--- name: zig-programming description: Writes Zig that matches the installed compiler version, with explicit allocator passing (DebugAllocator, arenas, std.testing.allocator),… license: MIT ---

Done well means: the code compiles on the exact Zig version the project pins, every allocation names its allocator and has a matching defer/errdefer, errors propagate with try and are handled exhaustively at the edge, tests run under std.testing.allocator with zero leaks, and zig build test is green.

Version first, always

Zig is pre-1.0 and every minor release breaks the standard library. Before writing a line:

  1. Run zig version and read .minimum_zig_version in build.zig.zon.
  2. Match the code to that version. Training-data Zig is often 0.11-0.13 and will not compile on 0.15+.
  3. When an API name fails, check the standard library source for that version (zig env prints lib_dir; grep std/ there) instead of guessing.
ReleaseChanges that break old code
0.14callconv(.c) lowercase; std.heap.GeneralPurposeAllocator renamed DebugAllocator; .root_module style build API
0.15“Writergate”: non-generic std.Io.Writer/std.Io.Reader with the buffer in the interface; {f} required to call format methods; std.ArrayList is unmanaged (allocator per call); addExecutable requires .root_module
0.16 (Apr 2026)I/O becomes an interface: file, socket, sleep and stdout functions take an io: std.Io argument; std.fs.File/Dir move to std.Io.File/std.Io.Dir; main may take std.process.Init; @cImport deprecated in favor of build-system translate-c; packages fetched into project-local zig-pkg/

If the project is on an older release, follow that release’s idioms rather than upgrading silently. Upgrading the compiler is its own task with its own commit.

Program entry and I/O (0.16)

const std = @import("std");

pub fn main(init: std.process.Init) !void {
    const gpa = init.gpa;               // general-purpose allocator chosen by the runtime
    const io = init.io;                 // default Io implementation
    const args = try init.minimal.args.toSlice(init.arena.allocator());

    var stdout_buf: [4096]u8 = undefined;
    var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buf);
    const out = &stdout_writer.interface;
    try out.print("{d} args\n", .{args.len});
    try out.flush();                    // buffered: forgetting flush prints nothing

    _ = gpa;
}
  • Pass io down to every function that does I/O, like you pass an allocator. Code that takes io can run on threaded or evented backends without changes.
  • Library code that has no Io available can create one (var threaded: std.Io.Threaded = .init_single_threaded; const io = threaded.io();), but prefer accepting io as a parameter.
  • std.debug.print writes to stderr unbuffered; use it for debugging, not program output.

Allocators

SituationAllocator
main on 0.16init.gpa
Debug builds where you want leak and double-free detectionstd.heap.DebugAllocator(.{}), defer _ = dbg.deinit(); (returns .leak on leaks)
Release, multi-threaded, general purposestd.heap.smp_allocator
Linking libc anywaystd.heap.c_allocator
Request/frame/parse scope: allocate many, free all at oncestd.heap.ArenaAllocator.init(parent), defer arena.deinit();
Hard upper bound, no heapstd.heap.FixedBufferAllocator.init(&buffer)
Testsstd.testing.allocator (fails the test on leaks)
fn loadNames(gpa: std.mem.Allocator, raw: []const u8) ![][]const u8 {
    var list: std.ArrayList([]const u8) = .empty;
    errdefer {
        for (list.items) |n| gpa.free(n);
        list.deinit(gpa);
    }
    var it = std.mem.tokenizeScalar(u8, raw, '\n');
    while (it.next()) |line| {
        const copy = try gpa.dupe(u8, line);
        errdefer gpa.free(copy);
        try list.append(gpa, copy);
    }
    return list.toOwnedSlice(gpa);      // caller frees each name and the slice
}
  • Functions that allocate take std.mem.Allocator as the first parameter (named gpa or allocator), and the doc comment says who frees the result.
  • Containers: std.ArrayList(T), std.AutoHashMapUnmanaged(K, V), std.StringHashMapUnmanaged(V) initialized with .empty, allocator passed to each mutating call and to deinit.
  • defer runs at scope exit always; errdefer only when the scope returns an error. Put the errdefer immediately after the acquisition it undoes.
  • A StringHashMap does not own its keys; dupe keys you insert and free them on removal.

Errors

const ConfigError = error{ MissingField, InvalidPort };

fn parsePort(s: []const u8) ConfigError!u16 {
    const v = std.fmt.parseInt(u16, s, 10) catch return error.InvalidPort;
    if (v == 0) return error.InvalidPort;
    return v;
}

// at the edge: handle every case explicitly
const port = parsePort(text) catch |err| switch (err) {
    error.InvalidPort => {
        std.log.err("invalid port '{s}'", .{text});
        return err;
    },
    error.MissingField => unreachable, // only if truly impossible here
};
  • Inferred error sets (!T) for internal functions; explicit sets for public APIs so callers can switch exhaustively.
  • catch unreachable and .? on optionals are assertions: they panic in Debug and ReleaseSafe and are undefined behavior in ReleaseFast/ReleaseSmall. Never use them on input-dependent values.
  • Error return traces print in Debug builds; run failing code in Debug before guessing.
  • Errors carry no payload. Return detail through an out-parameter diagnostics struct when callers need it (the pattern std.json uses).

Comptime and generics

fn Stack(comptime T: type) type {
    return struct {
        items: std.ArrayList(T) = .empty,
        const Self = @This();
        pub fn push(self: *Self, gpa: std.mem.Allocator, v: T) !void {
            try self.items.append(gpa, v);
        }
        pub fn pop(self: *Self) ?T {
            return self.items.pop();
        }
        pub fn deinit(self: *Self, gpa: std.mem.Allocator) void {
            self.items.deinit(gpa);
        }
    };
}
  • Prefer comptime T: type parameters over anytype; anytype hides the contract and produces errors deep inside the body. Use anytype for writer-like duck typing only.
  • Validate generic parameters early with @compileError after inspecting @typeInfo(T).
  • Keep comptime logic small and boring; heavy comptime metaprogramming slows builds and produces unreadable errors.

Slices, pointers and strings

TypeMeaning
[N]Tarray, value type, copied on assignment
[]T / []const Tslice: pointer + length
*Tsingle-item pointer, never null
?*Tnullable pointer
[*]Tmany-item pointer, no length (C interop)
[:0]const u8NUL-terminated slice: pass .ptr to C
  • Compare strings with std.mem.eql(u8, a, b), never ==.
  • Never return a slice into a stack array; return an allocated slice or take a caller buffer.
  • Appending to an ArrayList may reallocate and invalidate list.items pointers and slices taken earlier.

Integer safety

  • Overflow is a panic in Debug/ReleaseSafe. Use +% (wrap), +| (saturate), or std.math.add(T, a, b) (returns error.Overflow) when overflow is expected.
  • @intCast asserts the value fits; validate before casting input-derived values.
  • Ship with ReleaseSafe unless profiling proves the checks matter; use ReleaseFast per hot module, not for the whole program by default.

build.zig

const std = @import("std");

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

    const mod = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });

    const exe = b.addExecutable(.{ .name = "app", .root_module = mod });
    b.installArtifact(exe);

    const run = b.addRunArtifact(exe);
    if (b.args) |args| run.addArgs(args);
    b.step("run", "Run the app").dependOn(&run.step);

    const tests = b.addTest(.{ .root_module = mod });
    b.step("test", "Run unit tests").dependOn(&b.addRunArtifact(tests).step);
}
  • Add a dependency: zig fetch --save git+https://github.com/owner/repo#<tag-or-commit>, then const dep = b.dependency("repo", .{ .target = target, .optimize = optimize }); and mod.addImport("repo", dep.module("repo"));.
  • build.zig.zon needs .name, .version, .fingerprint (generated; never copy it between projects) and .minimum_zig_version.
  • Cross-compile: zig build -Dtarget=aarch64-linux-musl -Doptimize=ReleaseSafe.
  • zig cc / zig c++ are drop-in cross C compilers: CC="zig cc -target x86_64-linux-gnu.2.28" make pins the glibc version.

C interop

// build.zig: compile C sources and link libraries into the module
mod.addCSourceFiles(.{ .files = &.{ "vendor/miniz.c" }, .flags = &.{ "-std=c99", "-O2" } });
mod.addIncludePath(b.path("vendor"));
mod.linkSystemLibrary("sqlite3", .{});
mod.link_libc = true;
  • On 0.16 translate C headers in the build (b.addTranslateC(.{ .root_source_file = b.path("src/c.h"), ... })) and import the result as a module; on 0.15 and earlier @cImport(@cInclude("x.h")) is still the normal path.
  • Wrap raw C calls in a thin Zig layer: convert return codes to error unions, [*c] pointers to slices or optionals, and free C-allocated memory with the C library’s own free function.
  • Exporting to C: export fn mylib_add(a: c_int, b: c_int) c_int, extern struct for layout-compatible types, callconv(.c) for callbacks.

Tests

test "parsePort rejects zero" {
    try std.testing.expectError(error.InvalidPort, parsePort("0"));
    try std.testing.expectEqual(@as(u16, 8080), try parsePort("8080"));
}

test "loadNames frees on success" {
    const gpa = std.testing.allocator;
    const names = try loadNames(gpa, "a\nb\n");
    defer {
        for (names) |n| gpa.free(n);
        gpa.free(names);
    }
    try std.testing.expectEqual(@as(usize, 2), names.len);
}
  • std.testing.checkAllAllocationFailures exercises every allocation failure path; run it on parsers and builders that use errdefer.
  • zig build test --summary all shows every test; zig test src/x.zig --test-filter "name" runs one.

Gotchas

  • Unused locals and parameters are compile errors; discard with _ = x; only when the value is genuinely unused, not to silence work in progress.
  • var that is never mutated is an error; use const.
  • undefined memory is filled with 0xaa in Debug; reading it is a bug even when it seems to work.
  • Struct field order in non-extern structs is not guaranteed; use extern struct or packed struct for binary layouts.
  • Format specifiers: {s} strings, {d} numbers, {any} debug, {f} for types with a format method (0.15+), {x} hex.

Verification

  1. zig build and zig build test --summary all pass on the pinned version.
  2. Tests use std.testing.allocator; zero leaks reported.
  3. Build -Doptimize=ReleaseSafe and run the tests again (catches Debug-only assumptions).
  4. For C interop, run once under Valgrind or with the C code compiled with -fsanitize=address to catch leaks crossing the boundary.
  5. zig fmt --check . is clean.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc