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.
- name
zig-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
- Plain C with no Zig in the build — use c-systems
- Zig targeting the browser through wasm-bindgen-style glue — use webassembly-modules
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.
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:
- Run
zig versionand read.minimum_zig_versioninbuild.zig.zon. - Match the code to that version. Training-data Zig is often 0.11-0.13 and will not compile on 0.15+.
- When an API name fails, check the standard library source for that version
(
zig envprintslib_dir; grepstd/there) instead of guessing.
| Release | Changes that break old code |
|---|---|
| 0.14 | callconv(.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
iodown to every function that does I/O, like you pass an allocator. Code that takesiocan run on threaded or evented backends without changes. - Library code that has no
Ioavailable can create one (var threaded: std.Io.Threaded = .init_single_threaded; const io = threaded.io();), but prefer acceptingioas a parameter. std.debug.printwrites to stderr unbuffered; use it for debugging, not program output.
Allocators
| Situation | Allocator |
|---|---|
main on 0.16 | init.gpa |
| Debug builds where you want leak and double-free detection | std.heap.DebugAllocator(.{}), defer _ = dbg.deinit(); (returns .leak on leaks) |
| Release, multi-threaded, general purpose | std.heap.smp_allocator |
| Linking libc anyway | std.heap.c_allocator |
| Request/frame/parse scope: allocate many, free all at once | std.heap.ArenaAllocator.init(parent), defer arena.deinit(); |
| Hard upper bound, no heap | std.heap.FixedBufferAllocator.init(&buffer) |
| Tests | std.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.Allocatoras the first parameter (namedgpaorallocator), 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 todeinit. deferruns at scope exit always;errdeferonly when the scope returns an error. Put theerrdeferimmediately after the acquisition it undoes.- A
StringHashMapdoes 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 unreachableand.?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.jsonuses).
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: typeparameters overanytype;anytypehides the contract and produces errors deep inside the body. Useanytypefor writer-like duck typing only. - Validate generic parameters early with
@compileErrorafter inspecting@typeInfo(T). - Keep comptime logic small and boring; heavy comptime metaprogramming slows builds and produces unreadable errors.
Slices, pointers and strings
| Type | Meaning |
|---|---|
[N]T | array, value type, copied on assignment |
[]T / []const T | slice: pointer + length |
*T | single-item pointer, never null |
?*T | nullable pointer |
[*]T | many-item pointer, no length (C interop) |
[:0]const u8 | NUL-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
ArrayListmay reallocate and invalidatelist.itemspointers and slices taken earlier.
Integer safety
- Overflow is a panic in Debug/ReleaseSafe. Use
+%(wrap),+|(saturate), orstd.math.add(T, a, b)(returnserror.Overflow) when overflow is expected. @intCastasserts the value fits; validate before casting input-derived values.- Ship with
ReleaseSafeunless profiling proves the checks matter; useReleaseFastper 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>, thenconst dep = b.dependency("repo", .{ .target = target, .optimize = optimize });andmod.addImport("repo", dep.module("repo"));. build.zig.zonneeds.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" makepins 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 structfor 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.checkAllAllocationFailuresexercises every allocation failure path; run it on parsers and builders that useerrdefer.zig build test --summary allshows 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. varthat is never mutated is an error; useconst.undefinedmemory is filled with0xaain Debug; reading it is a bug even when it seems to work.- Struct field order in non-
externstructs is not guaranteed; useextern structorpacked structfor binary layouts. - Format specifiers:
{s}strings,{d}numbers,{any}debug,{f}for types with aformatmethod (0.15+),{x}hex.
Verification
zig buildandzig build test --summary allpass on the pinned version.- Tests use
std.testing.allocator; zero leaks reported. - Build
-Doptimize=ReleaseSafeand run the tests again (catches Debug-only assumptions). - For C interop, run once under Valgrind or with the C code compiled with
-fsanitize=addressto catch leaks crossing the boundary. 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.