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.
What You'll Learn
By the end of this lesson you will write three compile-time tools: a function that reports what category a type belongs to, a function that builds a struct type around a type you hand it, and a lookup table whose contents are computed by the compiler and shipped finished in the binary.
There is no capstone milestone attached to this lesson. What it is for is collapsing repetition the type system currently forces on you — the pair of near-identical functions you wrote at the end of String Handling is the first thing the next section takes apart — and adding the cheapest storage tier Zig offers, data that costs nothing at runtime because it was finished before the program started. You arrive here able to write concrete functions over a named type (String Handling, Arrays and Structs) and to reason about where a value's bytes live (Memory and Allocators); what is new is that some of your code will now run in the compiler rather than in the program. Everything below is pinned to Zig 0.16.0, the version the Run button uses; comptime is among the most stable parts of the language, so expect what you learn here to outlast the standard-library surfaces around it.
The duplication you already wrote
At the end of String Handling the build task made you write this pair:
fn countMatchingBytes(items: []const u8, wanted: u8) usize {
var n: usize = 0;
for (items) |item| {
if (item == wanted) n += 1;
}
return n;
}
fn countMatchingScores(items: []const i32, wanted: i32) usize {
var n: usize = 0;
for (items) |item| {
if (item == wanted) n += 1;
}
return n;
}
Five identical lines, twice. The bodies do not differ at all; the signatures differ in one word. And the duplication was unavoidable — a parameter needs a type written down, []const u8 and []const i32 are different types, and a function accepting one will not accept the other. Add a third element type and you copy the five lines again.
The reason that is unavoidable so far is that a type has only ever been something you write. This lesson removes that restriction. In Zig a type is an ordinary value — one that exists during compilation — so it can be passed to a function like any other argument, and the body written once can serve every type the argument might be:
const std = @import("std");
// One definition. T is a parameter, and its value is a type.
fn countMatching(comptime T: type, items: []const T, wanted: T) usize {
var n: usize = 0;
for (items) |item| {
if (item == wanted) n += 1;
}
return n;
}
pub fn main() !void {
std.debug.print("bytes: {}\n", .{countMatching(u8, "banana", 'a')});
std.debug.print("scores: {}\n", .{countMatching(i32, &[_]i32{ 7, 3, 7, 7 }, 7)});
}
Both answers are 3, from one body. That is what comptime buys, and it is why this lesson comes after ten lessons of writing things out concretely rather than before them: the tool is only legible once you have felt the duplication it removes. Everything below builds toward doing this deliberately — running code inside the compiler, inspecting types as data, and generating whole types on demand.
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.
The interesting question is what comptime actually does to a value. Predict this before you read on — the same blk: block appears twice, once behind comptime and once not.
Predict
Both blocks build the same array with the same loop. One is marked comptime, one is not. What is the difference in the program that ships?
const std = @import("std");
fn buildTable() [4]u32 {
var t: [4]u32 = undefined;
for (0..4) |i| t[i] = @intCast(i * 10);
return t;
}
pub fn main() !void {
// A: evaluated by the compiler; the array is baked into the binary
const baked = comptime buildTable();
// B: evaluated when the program runs
const computed = buildTable();
std.debug.print("{d} {d}\n", .{ baked[3], computed[3] });
}Both print 30. The difference is where the loop ran: comptime buildTable() executes inside the compiler and leaves only the finished array [0, 10, 20, 30] in the binary, so no loop for it exists in the shipped program. The unmarked call compiles to a real loop that runs every time main starts. Same source, same answer, work moved earlier. That is the whole bargain.
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", .{});
}
Notice repeat: its count is a comptime usize, and the return type [count]T depends on it. That dependency is where the most common comptime error lives. The program below looks harmless and refuses to compile — work out why before you change anything.
Debug
This program does not compile. The error mentions a comptime value it cannot resolve. Diagnose why the compiler cannot accept this call, then fix it so the program prints 'capacity = 16'.
const std = @import("std");
fn Buffer(comptime size: usize) type {
return struct {
data: [size]u8 = undefined,
const Self = @This();
fn capacity(self: Self) usize {
_ = self;
return size;
}
};
}
pub fn main() !void {
var requested: usize = 16;
requested += 0;
var buf = Buffer(requested){};
if (buf.capacity() != 16) {
std.debug.print("CHECK FAILED: capacity {d}\n", .{buf.capacity()});
return error.CheckFailed;
}
std.debug.print("capacity = {d}\n", .{buf.capacity()});
}Expected output:
capacity = 16
The bug is var requested. Because Buffer(size) builds a struct containing [size]u8, the compiler needs size while it is still deciding the struct's layout — before any of your code runs. A var is a runtime storage location, so the compiler reports argument to comptime parameter must be comptime-known. Changing it to const requested: usize = 16; (and dropping the += 0) makes the value comptime-known and the layout resolvable. The rule generalises: array lengths, type arguments and comptime parameters must all be knowable before the program starts.
Type Reflection with @typeInfo
Zig's built-in functions let you inspect types at compile time.
One new piece of syntax appears in the example below and needs naming first: inline for. An ordinary for produces a loop the program executes, with the loop variable holding a different value on each pass at runtime. An inline for is unrolled by the compiler instead — it copies the body once per element, and inside each copy the loop variable is a compile-time known value rather than a runtime one.
That distinction is not a performance nicety here; it is what makes the loop legal at all. The list being iterated is @typeInfo(T).@"struct".fields, and each element carries a type field. A type is a compile-time-only value, so a plain for over that list will not compile — the compiler rejects it with:
error: values of type 'builtin.Type.StructField' must be comptime-known, but index value is runtime-known
note: types are not available at runtime
inline for fixes it because unrolling makes each field comptime-known by construction. Rule of thumb: reach for inline for when the loop body needs the loop variable at compile time — using it as a type, passing it to a comptime parameter, or naming a field — and a plain for everywhere else.
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", .{});
}
That vowel_table is worth pausing on, because it is the first structure in this course that costs nothing at runtime. Check what you remember about where the earlier lessons put their data.
Recall
Without scrolling up: the vowel_table above is a 256-byte array built in a comptime block. In Memory and Allocators you built collections on the heap through an allocator, and in Arrays and Structs you saw fixed-size arrays on the stack. Where does vowel_table live, and what does it cost to build when the program runs?
So vowel_table is not on the heap and not filled at startup — the loop ran inside the compiler and the finished 256 bytes were written into the binary. isVowel is one indexed read. Comptime adds a third storage tier below the stack arrays of Arrays and Structs and the heap collections of Memory and Allocators: data that is already finished before the program starts.
Assemble one of those tables yourself. The lines below belong inside a function body, and only one arrangement builds the table before anything reads it.
Arrange the code
These lines build a table of the first eight squares inside a comptime block and then read one entry out of it. The pieces are shuffled. Put them in the only order that compiles and prints 49 — then answer what the order is really testing: the block has to produce a value before the block ends, so what does each line contribute to that value, and what is missing if it runs too early?
break :blk t;for (&t, 0..) |*slot, i| slot.* = @intCast(i * i);};std.debug.print("t[7]={}\n", .{table[7]});const table = comptime blk: {var t: [8]u32 = undefined;
Rust and TypeScript both compute at compile time too, but neither does it the way Zig does. Before the build task, work out which part of the idea actually crosses over.
Transfer
Rust has const fn, whose calls the compiler can evaluate while building. TypeScript has conditional types, which compute types during type-checking and vanish before any JavaScript runs. Zig has comptime. All three do work before the program runs. Which statement names what Zig genuinely shares with them, rather than a resemblance that stops at the description?
Try It Yourself
Reading about comptime is not the same as reaching for it. This is a build task: a small program that reports its own pass/fail. Three pieces are stubbed out — a type-inspecting function, a generic type factory, and a compile-time table builder. Run it as-is and it fails immediately, naming the first stub that is still empty. Implement each until it prints All checks passed.
The three stubs use exactly what this lesson taught: @typeInfo to branch on a type's category, fn(comptime T: type) type to build a struct around a type argument, and a comptime-evaluated loop to produce a finished array. The starter has the stubs and the checks; you write only the bodies. Nothing above spells out all three answers, so you will have to assemble them.
Build
Finish the build. Three comptime helpers are stubbed out and the checks below them fail until each returns the right thing. Run it as-is to see which check fails first, decide what that stub is missing, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
const std = @import("std");
fn expect(ok: bool, comptime msg: []const u8) !void {
if (!ok) {
std.debug.print("CHECK FAILED: {s}\n", .{msg});
return error.CheckFailed;
}
}
// TODO 1: return a label describing T's category, using @typeInfo.
// typeLabel(u16) -> "int" typeLabel(f32) -> "float"
// typeLabel(bool) -> "bool" anything else -> "other"
// The @typeInfo tags you need are .int, .float and .bool.
fn typeLabel(comptime T: type) []const u8 {
// your code here
_ = T;
return "";
}
// TODO 2: return a struct type holding two T values, with a method
// `larger` that returns whichever of the two is greater.
// const p = Pair(i32){ .a = 3, .b = 9 }; p.larger() -> 9
fn Pair(comptime T: type) type {
// your code here — replace this whole placeholder struct.
// It only exists so the checks below compile; `larger` always answers
// `a`, which is wrong for the very first Pair check.
return struct {
a: T,
b: T,
fn larger(self: @This()) T {
return self.a;
}
};
}
// TODO 3: return an array of the first n triangular numbers.
// triangle(5) -> [1, 3, 6, 10, 15] (running sums of 1, 2, 3, ...)
// Note the return type already depends on n — that is why n is comptime.
fn triangle(comptime n: usize) [n]u32 {
// your code here
var t: [n]u32 = undefined;
_ = &t; // keeps t a var until your loop fills it
return t;
}
// --- Build checks: Do not edit below this line. ---
pub fn main() !void {
try expect(std.mem.eql(u8, typeLabel(u16), "int"), "typeLabel(u16) should be the label int");
try expect(std.mem.eql(u8, typeLabel(f32), "float"), "typeLabel(f32) should be the label float");
try expect(std.mem.eql(u8, typeLabel(bool), "bool"), "typeLabel(bool) should be the label bool");
try expect(std.mem.eql(u8, typeLabel([]const u8), "other"), "typeLabel of a slice should be the label other");
const p = Pair(i32){ .a = 3, .b = 9 };
try expect(p.larger() == 9, "Pair(i32){ .a = 3, .b = 9 }.larger() should be 9");
const q = Pair(f64){ .a = 2.5, .b = 1.5 };
try expect(q.larger() == 2.5, "the same Pair should work for f64 without changes");
const t = comptime triangle(5);
try expect(t.len == 5, "triangle(5) should have 5 elements");
try expect(t[0] == 1, "the first triangular number is 1");
try expect(t[4] == 15, "the fifth triangular number is 15");
std.debug.print("All checks passed.\n", .{});
std.debug.print("typeLabel(bool) = {s}\n", .{typeLabel(bool)});
std.debug.print("Pair(f64) larger = {d}\n", .{q.larger()});
std.debug.print("triangle(5) last = {d}\n", .{t[4]});
}Expected output:
All checks passed. typeLabel(bool) = bool Pair(f64) larger = 2.5 triangle(5) last = 15
Once it passes, try two variations and predict each before running:
- Take the
comptimeoff the call. Changeconst t = comptime triangle(5);toconst t = triangle(5);and predict whether it still compiles. It does, and every check still passes —5is a literal, sonis comptime-known either way; what changes is that Zig is now free to evaluate the loop at runtime instead of being told to do it at compile time. The observable behaviour is identical, which is exactly whycomptimeon a call is about guaranteeing when, not about enabling what. - Ask
typeLabelabout a type it has no branch for. Addtry expect(std.mem.eql(u8, typeLabel(u16), "integer"), "label mismatch");and predict the outcome. The check fails withCHECK FAILED: label mismatchand a non-zero exit, becausetypeLabel(u16)returns"int"— an instructive failure showing that the comparison happens against a compile-time-known string the compiler already resolved.
Key Takeaways
comptimeruns ordinary Zig code at compile time- Use
comptimeparameters for generic programming — no separate generics syntax @typeInfolets you inspect types for metaprogramming- Compile-time lookup tables have zero runtime cost
- Functions returning
typecreate generic data structures inline forunrolls loops over compile-time known data
Pro Tip: When you find yourself wanting macros or code generation, reach for
comptimefirst. Since it uses the same language as runtime code, you can test compile-time logic at runtime, then add thecomptimekeyword once it works.
Next Steps
Comptime showed you how Zig evaluates code at compile time. Next, you'll put that to work writing functions that accept any type at all -- using anytype and comptime type parameters to build generic, reusable code without runtime cost.