Skip to lesson

learningzig.org / intermediate / 08-comptime · lesson 8 of 25

TL;DR

Zig comptime tutorial — learn compile-time evaluation, comptime parameters, type reflection, and how to write generic zero-cost abstractions. Free tutorial with runnable examples.

Key concepts

  • Zig comptime
  • compile time programming Zig
  • Zig metaprogramming
  • comptime tutorial
  • zig comptime examples
  • zig compile time execution

Comptime

Zig's comptime keyword lets you run code at compile time. This replaces macros and generics found in other languages with a single, unified mechanism that uses ordinary Zig code.

Compile-Time Values

Mark a variable or parameter as comptime to force evaluation at compile time.

const std = @import("std");

fn fibonacci(comptime n: u32) u64 {
    // Naive recursion revisits the same values many times. The compiler caps
    // comptime evaluation at 1000 backward branches by default to catch runaway
    // loops; @setEvalBranchQuota raises that budget so this can finish.
    @setEvalBranchQuota(100000);
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

pub fn main() !void {
    // These are computed at compile time — zero runtime cost
    const fib10 = comptime fibonacci(10);
    const fib15 = comptime fibonacci(15);
    const fib20 = comptime fibonacci(20);

    std.debug.print("fib(10) = {}\n", .{fib10});
    std.debug.print("fib(15) = {}\n", .{fib15});
    std.debug.print("fib(20) = {}\n", .{fib20});

    // Compile-time string processing
    const message = comptime blk: {
        var result: [5]u8 = "hello".*;
        for (&result) |*c| {
            c.* = std.ascii.toUpper(c.*);
        }
        break :blk result;
    };

    std.debug.print("Message: {s}\n", .{&message});
}

Because comptime code runs inside the compiler, Zig guards against accidental infinite loops with an evaluation budget — 1000 "backward branches" (loop iterations and recursive calls) by default. Naive recursive Fibonacci blows past that quickly, so fibonacci calls @setEvalBranchQuota to raise the limit. This is itself a piece of comptime tooling: it only affects compile-time evaluation and has no runtime cost.

Generic Functions with Comptime

Use comptime parameters to write generic functions — no special syntax needed.

const std = @import("std");

fn max(comptime T: type, a: T, b: T) T {
    return if (a > b) a else b;
}

fn sum(comptime T: type, items: []const T) T {
    var total: T = 0;
    for (items) |item| {
        total += item;
    }
    return total;
}

fn repeat(comptime T: type, value: T, comptime count: usize) [count]T {
    return [_]T{value} ** count;
}

pub fn main() !void {
    // Works with any numeric type
    std.debug.print("max(i32): {}\n", .{max(i32, 10, 20)});
    std.debug.print("max(f64): {d:.2}\n", .{max(f64, 3.14, 2.71)});

    const ints = [_]i32{ 1, 2, 3, 4, 5 };
    const floats = [_]f64{ 1.5, 2.5, 3.5 };

    std.debug.print("sum(i32): {}\n", .{sum(i32, &ints)});
    std.debug.print("sum(f64): {d:.1}\n", .{sum(f64, &floats)});

    const fives = repeat(i32, 5, 4);
    std.debug.print("repeat: ", .{});
    for (fives) |v| std.debug.print("{} ", .{v});
    std.debug.print("\n", .{});
}

Type Reflection with @typeInfo

Zig's built-in functions let you inspect types at compile time.

const std = @import("std");

const Point = struct {
    x: f32,
    y: f32,
    z: f32 = 0,
};

fn printStructInfo(comptime T: type) void {
    const info = @typeInfo(T);
    switch (info) {
        .@"struct" => |s| {
            std.debug.print("Struct '{s}' has {} fields:\n", .{ @typeName(T), s.fields.len });
            inline for (s.fields) |field| {
                std.debug.print("  - {s}: {s}", .{ field.name, @typeName(field.type) });
                if (field.default_value_ptr != null) {
                    std.debug.print(" (has default)", .{});
                }
                std.debug.print("\n", .{});
            }
        },
        else => std.debug.print("Not a struct\n", .{}),
    }
}

pub fn main() !void {
    printStructInfo(Point);

    // @typeInfo for basic types
    std.debug.print("\ni32 is {}-bit integer\n", .{@typeInfo(i32).int.bits});
    std.debug.print("f64 is {}-bit float\n", .{@typeInfo(f64).float.bits});
}

Compile-Time Arrays and Lookup Tables

Build data structures at compile time for instant runtime access.

const std = @import("std");

fn buildSquareTable(comptime size: usize) [size]u64 {
    var table: [size]u64 = undefined;
    for (0..size) |i| {
        table[i] = i * i;
    }
    return table;
}

const square_table = buildSquareTable(16);

// Built once at compile time, then indexed with a runtime byte at zero cost.
const vowel_table = blk: {
    var table: [256]bool = [_]bool{false} ** 256;
    for ("aeiouAEIOU") |v| {
        table[v] = true;
    }
    break :blk table;
};

fn isVowel(c: u8) bool {
    return vowel_table[c];
}

pub fn main() !void {
    std.debug.print("Square table:\n", .{});
    for (0..16) |i| {
        std.debug.print("  {}^2 = {}\n", .{ i, square_table[i] });
    }

    const word = "Hello World";
    std.debug.print("\nVowels in \"{s}\": ", .{word});
    for (word) |c| {
        if (isVowel(c)) std.debug.print("{c} ", .{c});
    }
    std.debug.print("\n", .{});
}

Try It Yourself

const std = @import("std");

// Generic stack using comptime
fn Stack(comptime T: type, comptime max_size: usize) type {
    return struct {
        items: [max_size]T = undefined,
        count: usize = 0,

        const Self = @This();

        fn push(self: *Self, value: T) !void {
            if (self.count >= max_size) return error.StackOverflow;
            self.items[self.count] = value;
            self.count += 1;
        }

        fn pop(self: *Self) !T {
            if (self.count == 0) return error.StackUnderflow;
            self.count -= 1;
            return self.items[self.count];
        }

        fn peek(self: *Self) !T {
            if (self.count == 0) return error.StackUnderflow;
            return self.items[self.count - 1];
        }

        fn isEmpty(self: *Self) bool {
            return self.count == 0;
        }
    };
}

pub fn main() !void {
    var int_stack = Stack(i32, 8){};

    try int_stack.push(10);
    try int_stack.push(20);
    try int_stack.push(30);

    std.debug.print("Top: {}\n", .{try int_stack.peek()});
    std.debug.print("Pop: {}\n", .{try int_stack.pop()});
    std.debug.print("Pop: {}\n", .{try int_stack.pop()});
    std.debug.print("Empty: {}\n", .{int_stack.isEmpty()});

    // Works with any type!
    var float_stack = Stack(f64, 4){};
    try float_stack.push(3.14);
    try float_stack.push(2.71);
    std.debug.print("\nFloat top: {d:.2}\n", .{try float_stack.peek()});
}

Key Takeaways

  • comptime runs ordinary Zig code at compile time
  • Use comptime parameters for generic programming — no separate generics syntax
  • @typeInfo lets you inspect types for metaprogramming
  • Compile-time lookup tables have zero runtime cost
  • Functions returning type create generic data structures
  • inline for unrolls loops over compile-time known data

Pro Tip: When you find yourself wanting macros or code generation, reach for comptime first. Since it uses the same language as runtime code, you can test compile-time logic at runtime, then add the comptime keyword once it works.

Next Steps

Comptime showed you how Zig evaluates code at compile time. Next, you'll learn about optionals and unions -- two type-system features that let you model "maybe a value" and "one of several shapes" safely and expressively.

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.