Zig's comptime: Generics Without a Macro System or a Runtime Bill
Zig's comptime replaces macros and templates with ordinary code evaluated by the compiler. Here's how it powers zero-cost generics, with a ring buffer example and the trade-offs.
14 Aug 2026, 11:57 UTC

If you've written C, you know the two bad options for generic code: void pointers with manual size bookkeeping, or preprocessor macros that expand into something no debugger can follow. C++ fixed this with templates, but at the cost of a famously complex subsystem. Zig takes a different route: one keyword, comptime, that lets ordinary Zig code run while the compiler runs. That single idea replaces macros, templates, and most code generation — and it leaves nothing behind in the compiled binary.
The problem comptime actually solves
Generic data structures need to know a type at compile time. In most languages that's a special-purpose feature bolted onto the type system. In Zig, a type is just a value that exists at compile time, so a generic container is an ordinary function that takes a type and returns a type. The standard library's ArrayList(T) is exactly this — a function you call with a type argument, evaluated by the compiler, producing a struct specialized for T. There is no separate template language to learn; the parameter list is the same syntax you already use.
The payoff is what systems programmers care about: the abstraction costs nothing at runtime. The specialized struct the compiler generates is the same code you would have written by hand for that concrete type.
A worked example: a fixed-size ring buffer generated by a function
Here's the pattern in miniature — a function that returns a struct type whose layout depends on a compile-time integer. Save this as ring.zig and build it with a recent Zig (the examples assume the 0.11–0.13 era API; check zig version first, since std library names shift between releases):
const std = @import("std");
fn RingBuffer(comptime T: type, comptime N: usize) type {
return struct {
buf: [N]T,
head: usize = 0,
len: usize = 0,
const Self = @This();
pub fn push(self: *Self, item: T) void {
self.buf[(self.head + self.len) % N] = item;
if (self.len < N) self.len += 1 else self.head = (self.head + 1) % N;
}
};
}
pub fn main() void {
var small = RingBuffer(u8, 4){};
var big = RingBuffer(u32, 1024){};
small.push(7);
big.push(7);
std.debug.print("{d} {d}\n", .{ small.len, big.len });
}Run zig build-exe ring.zig in a terminal (no special permissions needed). Two things are worth noticing. First, RingBuffer(u8, 4) and RingBuffer(u32, 1024) are two distinct types with different memory layouts, produced by the same source function. Second, RingBuffer itself never exists at runtime — it's a compile-time recipe. You can confirm this by inspecting the binary with zig objdump -x ring (or nm): there will be no symbol corresponding to RingBuffer, only the code for main and the monomorphized push methods.
Reflection without a separate metaprogramming layer
Because types are compile-time values, Zig can inspect them with builtins like @typeInfo, @TypeOf, and @This. This is how the standard library implements things like std.fmt: at compile time it walks the fields of whatever struct you hand it and generates the formatting code for that exact shape. Serialization, CLI argument parsing, and test runners all use the same trick — no external code generator, no build-step plugins.
When you're debugging a comptime-heavy function, @compileLog(...) prints values into the compiler's output during the build. It's the comptime equivalent of a printf, and it's the first tool to reach for when a type-level computation isn't producing what you expect.
The trade-offs you should know before committing
Comptime is not a free lunch. Three limitations matter in practice:
- No runtime-dependent side effects. Comptime code cannot do I/O or touch runtime state. If a value isn't known when the compiler runs, you can't use it. Attempting it is a compile error, which is good — but it shapes your design.
- Harder error messages. When a comptime evaluation fails, the error points at the evaluation step inside the generic function, not at your call site. Deep generic stacks can produce confusing traces;
@compileLogand small test instantiations help. - Compile time and code size. Every distinct instantiation generates fresh code, like C++ templates. A container instantiated for twenty types is twenty copies in your binary, and heavy comptime work slows builds.
There's also a stability caveat: Zig is pre-1.0, and std library APIs around comptime (and builtins' exact spellings) change between releases. Pin your toolchain version in a project and treat upgrades as a real task.
How to verify it's really zero-cost
Don't take the claim on faith — check it. Build the example above in release mode (zig build-exe -O ReleaseFast ring.zig), then disassemble and look at main. You should see ordinary struct field accesses, with no dispatch, no type metadata, and no trace of the RingBuffer function. Compare that against a hand-written struct { buf: [4]u8, head: usize, len: usize } version; the generated code should be effectively identical. That comparison — generic source, hand-written output — is the whole thesis of comptime, and it takes five minutes to confirm on your own machine.
If you're evaluating Zig for a project, comptime is the feature to prototype first: write one generic container you'd normally reach for a macro or generator to produce, and see whether the error messages and compile times are acceptable for your codebase. That experiment will tell you more than any feature list.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.