← History

Zig: comptime and no hidden anything

How one feature - running ordinary Zig at compile time - replaces macros, templates, and generics, while the language's vow that 'nothing happens unless you wrote it' keeps every allocation and every branch in plain sight.

Zig

Most systems languages bolt their metaprogramming on the side. C has a separate, typeless macro language (the preprocessor). C++ has templates - a second, accidentally-Turing-complete language that runs in the compiler and speaks a different dialect than the one you write functions in. Zig made a different bet: there is no second language. The compiler can simply run ordinary Zig during compilation. That single idea - comptime - absorbs macros, generics, templates, constant folding, and code generation into one mechanism, and it does so without violating Zig's central promise:

If it isn't written, it doesn't happen - no hidden control flow, no hidden allocations.

This article is about how those two things fit together. comptime is the power tool; "no hidden anything" is the constraint that keeps the power honest. Read through the lens this site cares about - memory - they reinforce each other: a value computed at comptime costs no allocation and no runtime work, and a language with no hidden allocations is one where you can actually audit where every byte comes from.

"No hidden anything," concretely

The slogan is easy to quote and easy to underestimate. It is really a list of specific things Zig refuses to do behind your back. It is worth being precise, because each refusal is a memory-management consequence.

Contrast a C++ line that looks innocent:

// Three things here can allocate or run code, none of them visible:
std::string greet(std::string name) {     // 1) copy-construct `name` (may allocate)
    return "Hello, " + name;               // 2) operator+ builds a new string (allocates)
}                                          // 3) ~string runs on `name` at scope exit

Three heap operations and a hidden destructor call, in a four-line function, none of them written as such. The C++ is not wrong - RAII is a genuinely strong answer to ownership - but the policy (where memory comes from, when code runs) is invisible at the call site. Zig's equivalent forces all of it onto the page:

const std = @import("std");

// Every cost is written down: the allocator, the failure, the free.
fn greet(allocator: std.mem.Allocator, name: []const u8) ![]u8 {
    return std.fmt.allocPrint(allocator, "Hello, {s}", .{name}); // `try` via `!`
}

pub fn main() !void {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer _ = gpa.deinit();                 // reports leaks at program exit
    const a = gpa.allocator();

    const msg = try greet(a, "world");
    defer a.free(msg);                       // YOU schedule the free; nothing implicit
    std.debug.print("{s}\n", .{msg});
}

The trade is explicit and honest: more typing, zero surprises. You give up the ergonomics of "just concatenate strings" and RAII's automatic destruction, and in return the function's memory behaviour is fully described by what you can see.

comptime: running Zig in the compiler

The verb form of the keyword tells the story. comptime expr means "evaluate this now, during compilation." Anything reachable as a comptime value - arithmetic, loops, function calls, even building data structures - runs in the compiler's interpreter and the result is baked into the binary. This is not a special sub-language; it is the same Zig, with the restriction that it cannot do anything that requires the program to be running (no I/O, no calling out to the OS, no runtime-only allocator).

A lookup table is the canonical example. In C you would reach for an X-macro or write the values out by hand. In Zig you write a normal loop and ask for it at compile time:

const std = @import("std");

// A `comptime`-evaluated block: an ordinary `for` loop runs in the compiler,
// and the const result lives in the binary's read-only data (.rodata).
const squares: [16]u32 = blk: {
    var t: [16]u32 = undefined;
    for (&t, 0..) |*slot, i| slot.* = @intCast(i * i);
    break :blk t;
};

pub fn main() void {
    // The compiler can PROVE this; if it were false, the build fails.
    comptime std.debug.assert(squares[12] == 144);
    std.debug.print("{d}\n", .{squares[12]}); // 144, precomputed, zero runtime cost
}

Notice the memory story. squares is a const whose value is fully known at compile time, so it is placed in .rodata - read-only static storage mapped directly from the executable. There is no allocator, no initialization loop at run time, and nothing to free. The work happened once, in the compiler. This is the same payoff C's static const table gives you, except you wrote it with a real loop instead of a macro, and the compiler type-checked every line.

comptime also applies to function parameters. A parameter marked comptime must be known at compile time, which is exactly what you need to pass a type as an argument - the foundation of Zig's generics.

Generics without a template language

Here is where the "one mechanism" claim pays off. C++ generics are templates: a separate substitution engine with its own (notoriously baroque) rules, SFINAE, partial specialization, and error messages that span pages. Zig has no template system at all. A generic function is just a normal function that takes a type as a comptime parameter and returns or uses it:

// `T` is a value of type `type`, known at comptime. The compiler runs the
// function body once per distinct T it's called with - monomorphization,
// but expressed as plain code, not a template DSL.
fn max(comptime T: type, a: T, b: T) T {
    return if (a > b) a else b;
}

// max(i32, 3, 9) => 9 ; max(f64, 1.5, 0.5) => 1.5

Because a type is just a comptime value, you can compute types. Generic data structures are functions that return a type:

const std = @import("std");

// A generic stack: `Stack` is a function returning a new struct type per T.
// This is exactly how std's containers (ArrayList, etc.) are written.
fn Stack(comptime T: type) type {
    return struct {
        items: []T,
        len: usize = 0,
        allocator: std.mem.Allocator,   // memory is still explicit, even in generics

        const Self = @This();

        fn init(allocator: std.mem.Allocator, cap: usize) !Self {
            return .{ .items = try allocator.alloc(T, cap), .allocator = allocator };
        }
        fn deinit(self: *Self) void {
            self.allocator.free(self.items);   // you free what you alloc'd
        }
        fn push(self: *Self, v: T) void {
            self.items[self.len] = v;
            self.len += 1;
        }
    };
}

pub fn main() !void {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer _ = gpa.deinit();

    var s = try Stack(u8).init(gpa.allocator(), 4);   // Stack(u8) is a concrete type
    defer s.deinit();
    s.push(7);
    std.debug.print("{d}\n", .{s.items[s.len - 1]}); // 7
}

Stack(u8) and Stack(f64) are two different concrete types, each fully monomorphized - no boxing, no runtime dispatch, no vtable, and (this is the recurring theme) the allocator is still a visible parameter. Generic code in Zig does not get a license to hide allocations; Stack(T) allocates only because you handed it an allocator and called a method that uses it.

Two things follow that C++ programmers will find striking. First, generic type errors are normal Zig type errors at the point of use, not template instantiation backtraces. Second, you can use the full standard library at comptime: @typeInfo lets you reflect over a type's fields at compile time and generate code from them - serializers, ORMs, and the like - all in ordinary Zig, no separate reflection or codegen step.

const std = @import("std");

// comptime reflection: sum every integer field of any struct, generated
// per-type at compile time. No runtime type info, no allocation.
fn sumFields(comptime T: type, v: T) i64 {
    var total: i64 = 0;
    inline for (std.meta.fields(T)) |field| {      // `inline for` unrolls at comptime
        total += @field(v, field.name);
    }
    return total;
}

const Point = struct { x: i32, y: i32, z: i32 };
// sumFields(Point, .{ .x = 1, .y = 2, .z = 3 }) => 6

inline for is the tell: the loop is unrolled at compile time, one iteration per field, because the field list is a comptime value. The generated code has no loop and no per-iteration overhead at run time.

Errors as values: error unions

Zig's "no hidden control flow" rule rules out exceptions, so error handling is done with values - but values blessed by the type system. A function that can fail returns an error union, written E!T: either an error from set E, or a T. The bare !T lets the compiler infer the error set from everything the function might return.

const std = @import("std");

const DivError = error{ NotANumber, DivByZero };

// Returns either a DivError or an i64. The error path is in the type.
fn divide100(s: []const u8) DivError!i64 {
    const n = std.fmt.parseInt(i64, s, 10) catch return DivError.NotANumber;
    if (n == 0) return DivError.DivByZero;
    return @divTrunc(100, n);
}

pub fn main() void {
    const r = divide100("4") catch |err| switch (err) {  // handle EACH variant
        error.NotANumber => return,
        error.DivByZero => return,
    };
    std.debug.print("{d}\n", .{r}); // 25
}

Three operators make this ergonomic, and all three are visible:

fn loadDoc(allocator: std.mem.Allocator) ![]u8 {
    const buf = try allocator.alloc(u8, 1024);
    errdefer allocator.free(buf);     // freed ONLY if a later step fails

    try fillFromDisk(buf);            // if this errors, errdefer frees buf, then returns
    return buf;                       // success: caller now owns buf (errdefer does NOT run)
}

This is the part exceptions get "for free" via stack unwinding and RAII - and Zig insists you write it. The cost is a line of code; the benefit is that the cleanup is right there, visible, and you can see exactly which allocations are released on which path. Because the error set is part of the type, the compiler also forces a switch over errors to be exhaustive, so adding a new error variant somewhere down the call tree surfaces as a compile error at every site that handles errors by name.

It is worth seeing how the same problem looks across the family this site covers, because each language draws the visibility/automation line in a different place:

/* C: status code + out-param. The error path is just another branch you
   write by hand, and you must remember to free on every one of them. */
enum { OK, NOT_A_NUMBER, DIV_BY_ZERO };
int divide100(const char *s, long *out) {
    char *end; errno = 0;
    long n = strtol(s, &end, 10);
    if (end == s || *end) return NOT_A_NUMBER;
    if (n == 0)           return DIV_BY_ZERO;
    *out = 100 / n; return OK;
}
// C++: throw and unwind. RAII frees automatically - at the price of an
// invisible control-flow path that exceptions introduce.
int divide100(const std::string& s) {
    std::size_t pos; int n = std::stoi(s, &pos);   // throws on bad parse
    if (n == 0) throw std::domain_error("div by zero");
    return 100 / n;
}
// Hare: tagged-union return; the caller `match`es every case. Closest in
// spirit to Zig - errors are values in the type - with `?` to propagate.
fn divide_100(s: str) (i64 | strconv::invalid | strconv::overflow | divbyzero) = {
	const n = strconv::stoi64(s)?;     // `?` forwards invalid OR overflow
	if (n == 0) return divbyzero;
	return 100 / n;
};
// Odin: multi-value return with the error last; `or_return` propagates it.
divide_100 :: proc(s: string) -> (result: int, err: Div_Error) {
	n, ok := strconv.parse_int(s)
	if !ok    { return 0, .Not_A_Number }
	if n == 0 { return 0, .Div_By_Zero }
	return 100 / n, .None
}
\ Forth: push a result AND a success flag on the stack; caller branches.
\ The RPN form of a C status code. CATCH/THROW exist for non-local exits.
: DIVIDE-100 ( n -- q flag )
  DUP 0= IF DROP 0 FALSE EXIT THEN   \ divide by zero -> flag FALSE
  100 SWAP / TRUE ;                   \ success -> quotient + TRUE
// HolyC (TempleOS): like C, a sentinel return plus an OUT param via
// pointer. TempleOS also has throw/try/catch for unrecoverable faults,
// but with no memory protection in ring 0, you still hand-pair MAlloc/Free.
I64 Divide100(U8 *s, I64 *ok) {
  U8 *end; I64 n = Str2I64(s, 10, &end);
  if (end == s || *end || n == 0) { *ok = FALSE; return 0; }
  *ok = TRUE; return 100 / n;
}

The spectrum is clear: C/HolyC and Forth make failure a hand-managed value with no automation, C++ automates cleanup but at the cost of hidden unwinding, and Zig/Hare/Odin put the error in the type system and force you to handle it - Zig's try/catch/errdefer being the most fully worked-out version of "errors are values and cleanup is visible."

Optionals: null is opt-in

A close cousin of error unions, and another instance of "no hidden anything," is the optional type ?T. A plain pointer in Zig (*T) cannot be null - it is statically guaranteed to point at something. If you want the possibility of "nothing," you ask for it with ?, and then the compiler forces you to unwrap it before use:

fn firstEven(xs: []const i32) ?i32 {   // may return "nothing"
    for (xs) |x| if (@mod(x, 2) == 0) return x;
    return null;
}

// Caller must deal with the null case explicitly:
const v = firstEven(&.{ 1, 3, 5 }) orelse -1;   // -1 if none
// or: if (firstEven(xs)) |x| { use x } else { ... }

This eliminates the entire category of null-pointer dereferences not by checking at run time but by making "could be null" a distinct, visible type. The memory relevance: a ?*T is the type for an optional owned pointer, and the compiler will not let you free or dereference it without first confirming it is non-null.

C interop: comptime, again

Zig's C interoperability is one of its headline features, and it is built on - what else - comptime. The @cImport builtin runs the C compiler/preprocessor at compile time and translates the resulting declarations into a real Zig namespace. There is no binding-generation step and no hand-written FFI shims:

const std = @import("std");

// Runs the C preprocessor at comptime and exposes the C decls as Zig.
const c = @cImport({
    @cInclude("stdio.h");   // puts
    @cInclude("string.h");  // strdup
    @cInclude("stdlib.h");  // free
});

pub fn main() void {
    _ = c.puts("Hello from libc");   // direct C call over the C ABI

    // strdup's result is backed by C's malloc -> it must be freed with C's
    // free, NOT a Zig allocator. The two heaps are different; mixing corrupts.
    const dup: [*c]u8 = c.strdup("owned by C") orelse return;
    defer c.free(dup);               // pair the C free beside the C alloc
    _ = c.puts(dup);
}
// build: zig build-exe main.zig -lc

The memory lesson here is the one that haunts every FFI boundary: a pointer that crosses the C ABI carries no lifetime with it. strdup allocated on C's malloc heap, so it must be released with C's free, never with a Zig std.mem.Allocator (which manages an entirely separate heap). Zig does not paper over this - it makes you write c.free, keeping the "no hidden allocations" rule intact straight across the language boundary. Note also [*c]u8, the C-pointer type: it is deliberately distinct from Zig's safe []u8 slices and *T pointers, marking exactly where you have left Zig's guarantees and entered C's looser world.

Beyond @cImport, the toolchain is half the story. Zig bundles a full C/C++ compiler frontend (zig cc) and libc sources for many targets, so zig cc is a drop-in, cross-compiling C compiler and Zig can link C objects directly. For a language whose pitch is "a better C," being able to consume any C library with no glue is not a nicety - it is the on-ramp.

Where the other languages sit on comptime

Compile-time execution is the axis that most separates this family, so it is worth placing each one precisely. The point is not that Zig "wins" - it is that each language drew the line where its philosophy pointed.

// C++ - REAL compile-time execution, but via a separate template/constexpr
// machinery. constexpr runs at compile time; templates are a second language.
template <std::size_t N>
constexpr std::array<int, N> squares() {
    std::array<int, N> t{};
    for (std::size_t i = 0; i < N; ++i) t[i] = int(i * i);
    return t;                       // computed by the compiler, lands in .rodata
}
static constexpr auto table = squares<16>();   // static_assert can verify it

C++ genuinely runs C++ at compile time (constexpr/consteval), and templates monomorphize like Zig generics. The difference is unity: in Zig it is all one language and one keyword; in C++ it is templates plus constexpr plus the preprocessor - three overlapping systems with three sets of rules.

/* C - the ONLY metaprogramming is the typeless preprocessor. The compiler
   constant-folds, but you cannot run a loop at compile time. X-macros are
   the closest thing to codegen: one list expanded into a .rodata table. */
#define SQUARES X(0) X(1) X(2) X(3)
static const int table[] = {
#define X(n) [n] = (n)*(n),
    SQUARES
#undef X
};

C has no compile-time execution of real C - only textual substitution and constant folding. HolyC (Terry A. Davis's dialect, the native language of his single-developer TempleOS) is the same model: it has the #define preprocessor but no comptime. Because TempleOS JIT-compiles and runs each top-level line as it loads, the idiomatic "precomputed table" is simply a data-segment array filled by a one-time loop at load - no heap, nothing to free. It is a thoughtful, pragmatic design for a system that ran entirely in ring 0 with no memory protection; it just was not aiming at compile-time metaprogramming.

// HolyC - no comptime. Top-level code runs at load (JIT), so a table is
// filled once into the data segment; the #define preprocessor is the only
// "meta" facility, with C's usual macro double-evaluation caveats.
#define SQR(x) ((x) * (x))
I64 table[16];
I64 i; for (i = 0; i < 16; i++) table[i] = i * i;   // runs once at load
// Odin - no general comptime-function execution like Zig, but constant
// declarations (`::`) ARE compile-time, and `$T` gives template-style
// parametric polymorphism (monomorphized per type at the call site).
sqr :: proc(x: $T) -> T { return x * x }
table :: [16]int{ /* literal values; lives in .rodata */ }

Odin deliberately omits Zig-style arbitrary comptime function execution. It has compile-time constants and parametric polymorphism ($T), which covers generics, but it does not let you run general procedures in the compiler - a conscious choice to keep the language smaller and more predictable.

// Hare - NO comptime and NO macros, by design. The language is kept tiny
// and freezable; a "precomputed table" is just a const global the compiler
// places in read-only data, and reuse is via plain typed functions.
def N: size = 16;
const table: [N]int = [0, 1, 4, 9, /* ... */];
fn sqr(x: int) int = x * x;     // no generics; write the function you need

Hare goes furthest the other direction: no macros, no compile-time execution, no generics. That is not an oversight but the whole ethos - a minimal, specifiable language with almost no runtime to hide anything in. You get the same .rodata table; you just write the values.

\ Forth - the EXTREME end: the compiler is itself ordinary Forth words, and
\ an IMMEDIATE word runs AT COMPILE TIME. A program can extend its own
\ compiler. The table below is generated during compilation by a real loop.
CREATE TABLE  16 0 DO  I I *  ,  LOOP   \ ',' compiles each square into data space
: SQR ( n -- n*n )  DUP * ;             \ untyped, so implicitly "generic"

Forth is the philosophical mirror image of C++: where C++ has a heavyweight separate compile-time language, Forth makes compile-time and run-time the same language by being homoiconic - IMMEDIATE words execute during compilation, so the program can extend its own compiler. Its CREATE ... , idiom generates the table at compile time with an ordinary loop, much as Zig's comptime block does, just with no type system to check it.

So the family spreads across a full spectrum: Hare (none), C/HolyC (textual macros only), Odin (constants + parametric polymorphism), Zig and C++ (real compile-time execution of the language), and Forth (the program is the compiler). Zig's distinctive position is offering the full power of C++'s compile-time world through one unified, ordinary-looking mechanism - and pairing it with the discipline that nothing, at compile time or run time, happens unless you wrote it.

Why this matters for memory

Pull the threads together and a coherent memory philosophy emerges. comptime is, among other things, a tool for moving work out of the run time entirely - tables, dispatch decisions, type-specialized code, and constant data all become compiler output that lands in .rodata with zero allocation and zero init cost. Generics built on comptime monomorphize, so there is no boxing and no hidden indirection. Error unions and optionals push failure and absence into the type system, so the cleanup that has to happen on those paths is written down (via errdefer) rather than conjured by an unwinder. And "no hidden allocations" means that after all that, the one thing left to manage at run time - the heap - is something you can see at every call site and audit with a leak-detecting allocator.

The recurring trade is the same one this whole site circles: Zig asks you to write down what other languages hide. More keystrokes, fewer surprises. For systems software, where knowing precisely where memory comes from and when code runs is not a chore but the entire job, that is the bargain worth making - and comptime is what makes the explicit version powerful enough to live with.