Zig's comptime: One Mechanism That Replaces Macros, Templates, and Code Generators
Zig's comptime replaces macros, templates, and code generators with one mechanism: ordinary Zig functions executed at compile time. A look at generics, a worked JSON serializer, and the real costs.
12 Dec 2025, 19:03 UTC

Every systems language eventually grows a second language for metaprogramming. C has the preprocessor. C++ has templates, then constexpr bolted on top. Rust has macros and generics as separate systems. You end up debugging two languages at once, and the error messages from the meta one are usually worse.
Zig's answer is comptime: the same Zig you already write, executed during compilation when the inputs are known. There is no separate template syntax, no macro language, no external code generator. This post looks at what that buys you in practice, with a concrete example, and where the sharp edges are.
Generics without a generics system
In Zig, a generic container is just a function that takes a type and returns a type. Types are first-class values at compile time, so fn Stack(comptime T: type) type is the whole mechanism. When you call Stack(i32), the function runs at compile time and produces a struct specialized to i32. The standard library's ArrayList(T) works exactly this way.
The practical consequence: there is nothing new to learn. If you can write a Zig function, you can write a generic. And because specialization happens per call site, Stack(i32) and Stack([]u8) are distinct types with no runtime dispatch and no boxing — the same monomorphization you'd get from C++ templates, without the template syntax or the two-phase lookup surprises.
A worked example: compile-time JSON serialization
The more interesting use is reflection. @typeInfo gives you a struct's fields at compile time, so you can generate a serializer specialized to a type with zero runtime reflection cost:
const std = @import("std");
pub fn jsonStringify(comptime T: type, value: T, writer: anytype) !void {
const info = @typeInfo(T);
try writer.writeAll("{");
inline for (info.@"struct".fields, 0..) |field, i| {
if (i > 0) try writer.writeAll(",");
try writer.print("\"{s}\":", .{field.name});
const v = @field(value, field.name);
switch (@typeInfo(field.type)) {
.int => try writer.print("{d}", .{v}),
.bool => try writer.writeAll(if (v) "true" else "false"),
else => @compileError("unsupported field type: " ++ @typeName(field.type)),
}
}
try writer.writeAll("}");
}Two things are worth noticing. First, inline for unrolls the loop at compile time, so the generated code is a straight sequence of writes — no iteration over field metadata at runtime. Second, @compileError turns "you passed a type I can't serialize" into a build failure with a readable message, rather than a runtime error or a template instantiation backtrace.
Run this with zig test on a small struct, then inspect the result with zig build-exe -femit-asm if you want to confirm the serializer really is specialized. That's the honest way to verify zero-overhead claims: look at the assembly, don't trust the blog post (including this one).
Validation that fails the build, not the deploy
Because any function can run at compile time when its inputs are comptime-known, validation moves left. Configuration parsing, lookup-table generation, and constraint checks can all happen in comptime blocks. A lookup table that would be initialized at program startup in C can be a comptime-computed constant in Zig — same code shape, zero startup cost, and the compiler will tell you if the computation is invalid.
The build system itself is the same story: build.zig is a Zig program, not a declarative DSL. Cross-compilation targets, link flags, and code generation steps are ordinary function calls, which means you can compute them, factor them into helpers, and unit-test your build logic. Compare that with the CMake or Bazel experience of debugging a language you only use twice a year.
The trade-offs are real
Comptime execution runs in an interpreter inside the compiler, and it has a branch quota (historically a default of 1000 backwards branches, raised with @setEvalBranchQuota). Naive compile-time algorithms — say, generating a large table with a recursive function — will hit the quota and force you to either bump it or write the algorithm iteratively. This is a deliberate guard against runaway metaprograms, but it means comptime code sometimes needs to be written differently than the runtime equivalent.
Compile times are the other cost. Heavy comptime use makes the compiler do real work, and Zig's compiler is not yet famous for speed. A project that generates serializers, parsers, and tables for dozens of types will feel it. The mitigation is the same as with C++ templates: generate what you need, not everything you could.
Finally, comptime reflection is structural, not semantic. @typeInfo tells you a field exists and what its type is; it cannot tell you whether skipping it in serialization is a bug. Generated code still needs tests like any other code — the difference is your tests can run against the generated specialization directly.
Where to start
Pick one thing you currently do with a macro, a code generator, or runtime reflection, and rewrite it as a comptime function. A struct-field printer using @typeInfo is a good first exercise: small, self-contained, and verifiable with zig test. Check zig version first and read the language reference for your installed release — Zig is pre-1.0, and details like the exact @typeInfo enum layout have changed between versions. The mechanism is stable; the spellings are not always.
The bigger idea is worth internalizing: metaprogramming stops being a separate skill. The same function you write for runtime can run at compile time, and the compiler — not a second toolchain — decides which. That's one language to debug instead of two, and for a systems programmer that may be the most convincing feature Zig has.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.