Skip to content

Compile-Time Loops

A compile-time loop is a loop that runs while Zig is compiling your program.

A compile-time loop is a loop that runs while Zig is compiling your program.

The loop does not run after the final executable starts. The compiler runs it earlier, uses the result, and then produces normal machine code.

This is useful when the loop works with information that is already known: types, fixed lists, array sizes, field names, enum tags, build options, or constant values.

Runtime Loops

A normal loop usually runs at runtime.

const std = @import("std");

pub fn main() void {
    var i: usize = 0;

    while (i < 3) : (i += 1) {
        std.debug.print("{}\n", .{i});
    }
}

This prints:

0
1
2

The loop counter i exists while the program is running. The program checks i < 3 each time.

Compile-Time Loops

Now place a loop inside a comptime block:

comptime {
    var i: usize = 0;

    while (i < 3) : (i += 1) {
        @compileLog(i);
    }
}

This loop runs during compilation.

The final program does not contain this loop. The compiler has already executed it.

@compileLog prints messages from the compiler, not from the final program.

Looping to Build a Constant

A common use of compile-time loops is building fixed data.

const squares = comptime blk: {
    var values: [5]u32 = undefined;

    for (0..5) |i| {
        values[i] = @intCast(i * i);
    }

    break :blk values;
};

This creates this array during compilation:

.{ 0, 1, 4, 9, 16 }

At runtime, the program already has the finished array. It does not need to compute the squares again.

inline for

inline for asks Zig to unroll the loop at compile time.

Example:

const std = @import("std");

pub fn main() void {
    inline for (.{ "red", "green", "blue" }) |name| {
        std.debug.print("{s}\n", .{name});
    }
}

This behaves like writing:

std.debug.print("{s}\n", .{"red"});
std.debug.print("{s}\n", .{"green"});
std.debug.print("{s}\n", .{"blue"});

The loop is not a normal runtime loop. The compiler expands each iteration.

Why inline for Matters

A normal for loop uses one body repeatedly.

An inline for creates a separate body for each item.

That matters when each iteration has a different type.

const std = @import("std");

pub fn main() void {
    inline for (.{ i32, bool, f64 }) |T| {
        std.debug.print("{s}\n", .{@typeName(T)});
    }
}

Here, each item is a type.

Types are compile-time values. The compiler must know T for each iteration. inline for makes that possible.

The output is:

i32
bool
f64

Normal Loops Cannot Iterate Over Types at Runtime

This does not make sense as a runtime loop:

for (.{ i32, bool, f64 }) |T| {
    std.debug.print("{s}\n", .{@typeName(T)});
}

The values i32, bool, and f64 are types. They belong to compile time. They are not ordinary runtime values.

Use inline for when the loop body depends on compile-time values such as types.

Compile-Time Loops with switch

Compile-time loops are useful with type-based branching.

const std = @import("std");

fn printDefaults() void {
    inline for (.{ bool, u8, i32 }) |T| {
        const value: T = switch (T) {
            bool => false,
            u8 => 0,
            i32 => 0,
            else => unreachable,
        };

        std.debug.print("{s}: {}\n", .{ @typeName(T), value });
    }
}

The compiler expands the loop once for each type.

Conceptually, it becomes:

{
    const value: bool = false;
    std.debug.print("{s}: {}\n", .{ "bool", value });
}
{
    const value: u8 = 0;
    std.debug.print("{s}: {}\n", .{ "u8", value });
}
{
    const value: i32 = 0;
    std.debug.print("{s}: {}\n", .{ "i32", value });
}

This is not dynamic type inspection. The compiler already knows each type.

Repeating Code Without Macros

Many languages use macros for this kind of work.

Zig usually uses inline for and comptime.

Example:

fn isInteger(comptime T: type) bool {
    inline for (.{ u8, u16, u32, u64, i8, i16, i32, i64 }) |Int| {
        if (T == Int) {
            return true;
        }
    }

    return false;
}

Usage:

const a = isInteger(u32); // true
const b = isInteger(bool); // false

The loop runs during compilation because T is a compile-time type.

Compile-Time Loops Can Reject Code

You can use a compile-time loop to enforce rules.

fn requireInteger(comptime T: type) void {
    inline for (.{ u8, u16, u32, u64, i8, i16, i32, i64 }) |Int| {
        if (T == Int) {
            return;
        }
    }

    @compileError("expected an integer type");
}

This works:

requireInteger(i32);

This fails during compilation:

requireInteger(bool);

The program does not reach runtime. Zig rejects the wrong type before producing the executable.

Building a Lookup Table

Suppose you want a table of byte values converted to hexadecimal characters.

fn makeHexTable() [16]u8 {
    comptime var table: [16]u8 = undefined;

    inline for (0..16) |i| {
        table[i] = if (i < 10)
            '0' + i
        else
            'a' + (i - 10);
    }

    return table;
}

const hex_chars = makeHexTable();

The final hex_chars value is known at compile time.

It is equivalent to:

const hex_chars = [_]u8{
    '0', '1', '2', '3',
    '4', '5', '6', '7',
    '8', '9', 'a', 'b',
    'c', 'd', 'e', 'f',
};

The loop helped you write the table clearly, but the runtime program receives the completed data.

inline while

Zig also supports inline while.

const std = @import("std");

pub fn main() void {
    comptime var i: usize = 0;

    inline while (i < 3) : (i += 1) {
        std.debug.print("{}\n", .{i});
    }
}

The compiler expands each loop iteration.

This is less common than inline for, but it is useful when the loop shape is easier to express with a counter.

Runtime Cost

A compile-time loop can reduce runtime cost because the work happens before the program starts.

But this does not mean every loop should become compile-time.

Use compile-time loops for program structure and fixed data.

Use runtime loops for real input.

Good compile-time loop uses:

types
fields
enum tags
fixed tables
static configuration
known sizes

Good runtime loop uses:

user input
file data
network data
database rows
command-line arguments
current time

A Common Mistake

Beginners sometimes try to use inline for for speed.

inline for (items) |item| {
    process(item);
}

This only works when items is compile-time known.

If items comes from a file or user input, it belongs to runtime. A normal for loop is correct.

Compile-time looping is about when information is known, not about making every loop faster.

Mental Model

A normal loop says:

Repeat this while the program runs.

A compile-time loop says:

Repeat this while the compiler builds the program.

An inline for says:

Create one copy of this code for each compile-time item.

That is the core idea.

Compile-time loops let you write compact source code while still producing specialized runtime code.