What Comptime Actually Is

Most languages have some form of compile-time evaluation. C has #define macros and #ifdef conditional compilation. C++ has constexpr and templates. Rust has const fn, macros, and generics. Each of these is a separate mechanism with its own syntax, rules, and limitations.

Zig has one mechanism: comptime. Any expression can be evaluated at compile time, and the same language constructs work at both compile time and runtime. There's no separate macro language, no template syntax, no generic annotations. Normal Zig code is written and parts of it are marked as comptime.

// This is a regular function that can run at compile time OR runtime
fn fibonacci(n: u64) u64 {
 if (n <= 1) return n;
 return fibonacci(n - 1) + fibonacci(n - 2);
}

// Compile-time evaluation
const fib_20 = comptime fibonacci(20); // Computed at compile time: 6765

// Runtime evaluation
fn getfib(n: u64) u64 {
 return fibonacci(n); // Computed at runtime
}

This looks simple, maybe even trivial. But the implications cascade. A real example illustrates the point.

Replacing Generics with Comptime

In C, creating a generic data structure requires void pointers and macros:

// C: Generic dynamic array via macros
#define DEFINE_VEC(T) \
 typedef struct { \
 T* items; \
 size_t len; \
 size_t cap; \
 } Vec_##T; \
 \
 void vec_##T##_push(Vec_##T* v, T item) { \
 if (v->len >= v->cap) { \
 v->cap = v->cap ? v->cap * 2 : 8; \
 v->items = realloc(v->items, sizeof(T) * v->cap); \
 } \
 v->items[v->len++] = item; \
 }

DEFINE_VEC(int)
DEFINE_VEC(float)
// Generates Vec_int, Vec_float, etc.

This is unreadable, undebuggable, and produces horrifying error messages. In Zig, the same concept uses comptime types:

fn ArrayList(comptime T: type) type {
 return struct {
 items: []T,
 len: usize,
 capacity: usize,
 allocator: std.mem.Allocator,

 const Self = @This();

 pub fn init(allocator: std.mem.Allocator) Self {
 return Self{
 .items = &[_]T{},
 .len = 0,
 .capacity = 0,
 .allocator = allocator,
 };
 }

 pub fn append(self: *Self, item: T) !void {
 if (self.len >= self.capacity) {
 const new_cap = if (self.capacity == 0) 8 else self.capacity * 2;
 self.items = try self.allocator.realloc(self.items, new_cap);
 self.capacity = new_cap;
 }
 self.items[self.len] = item;
 self.len += 1;
 }

 pub fn deinit(self: *Self) void {
 self.allocator.free(self.items);
 }
 };
}

// Usage, the compiler generates a specialized struct for each type
var list = ArrayList(u32).init(allocator);
defer list.deinit();
try list.append(42);

The comptime T: type parameter means T must be known at compile time. The function returns a type, it literally generates a struct definition. This is not a template instantiation; it's running actual Zig code that produces a type. The body can contain if statements, loops, or any other construct:

fn Matrix(comptime T: type, comptime rows: usize, comptime cols: usize) type {
 // Compile-time validation
 if (@typeInfo(T) != .Float and @typeInfo(T) != .Int) {
 @compileError("Matrix requires a numeric type, got " ++ @typeName(T));
 }

 if (rows == 0 or cols == 0) {
 @compileError("Matrix dimensions must be non-zero");
 }

 return struct {
 data: [rows][cols]T,

 const Self = @This();

 // Generate optimized multiplication based on dimensions
 pub fn multiply(self: Self, other: Matrix(T, cols, comptime other_cols: usize)) Matrix(T, rows, other_cols) {
 var result: Matrix(T, rows, other_cols) = undefined;
 inline for (0..rows) |i| {
 inline for (0..other_cols) |j| {
 var sum: T = 0;
 inline for (0..cols) |k| {
 sum += self.data[i][k] * other.data[k][j];
 }
 result.data[i][j] = sum;
 }
 }
 return result;
 }
 };
}

The inline for unrolls the loops at compile time. For a 4x4 matrix multiplication, the compiler generates 64 multiply-add instructions with no loop overhead. This is the kind of optimization that would require intrinsics or hand-written assembly in C.

The Packet Parser: A Real Example

A compelling demonstration of comptime's power emerges when building a DNS packet parser. A typical C implementation involves pointer arithmetic, manual bounds checking, and conditional logic for different record types, code that is notoriously fragile and difficult to maintain.

The Zig version uses comptime to generate parsers for different DNS record types:

const DnsRecordType = enum(u16) {
 A = 1,
 AAAA = 28,
 CNAME = 5,
 MX = 15,
 TXT = 16,
 SRV = 33,
};

fn RecordData(comptime rtype: DnsRecordType) type {
 return switch (rtype) {
 .A => struct { addr: [4]u8 },
 .AAAA => struct { addr: [16]u8 },
 .CNAME => struct { name: []const u8 },
 .MX => struct { priority: u16, exchange: []const u8 },
 .TXT => struct { texts: []const []const u8 },
 .SRV => struct {
 priority: u16,
 weight: u16,
 port: u16,
 target: []const u8,
 },
 };
}

fn parseRecord(comptime rtype: DnsRecordType, data: []const u8) !RecordData(rtype) {
 var result: RecordData(rtype) = undefined;

 switch (rtype) {
 .A => {
 if (data.len < 4) return error.TruncatedPacket;
 result.addr = data[0..4].*;
 },
 .AAAA => {
 if (data.len < 16) return error.TruncatedPacket;
 result.addr = data[0..16].*;
 },
 .MX => {
 if (data.len < 2) return error.TruncatedPacket;
 result.priority = std.mem.readInt(u16, data[0..2], .big);
 result.exchange = try decompressName(data[2..]);
 },
 // ... other types
 }

 return result;
}

At compile time, if parseRecord(.A, some_data) is called, the compiler generates a function that only handles A records, the switch branches for AAAA, MX, etc. are eliminated entirely. Zero runtime overhead for the type dispatch.

But the real power is compile-time validation:

comptime {
 // Verify at compile time that all record types have parsers
 inline for (std.meta.fields(DnsRecordType)) |field| {
 const rtype: DnsRecordType = @enumFromInt(field.value);
 // This forces the compiler to instantiate the parser
 // for every record type, catching errors at compile time
 _ = RecordData(rtype);
 }
}

If a new record type is added to the enum but the corresponding case is missing from the switch in RecordData, the code won't compile. In C, that would be a runtime crash on the first packet with the new record type, likely at 3 AM.

Error Handling: The Underrated Feature

Discussions about Zig tend to focus on comptime, but the error handling is equally important. Zig uses error unions, a value is either the expected type or an error:

const ParseError = error{
 TruncatedPacket,
 InvalidHeader,
 LabelTooLong,
 CompressionLoop,
 BufferOverflow,
};

fn parseDnsMessage(buf: []const u8) ParseError!DnsMessage {
 if (buf.len < 12) return error.TruncatedPacket;

 const header = parseHeader(buf[0..12]);

 var offset: usize = 12;
 var questions: [MAX_QUESTIONS]Question = undefined;

 for (0..header.qdcount) |i| {
 // The `try` keyword propagates errors automatically
 questions[i] = try parseQuestion(buf, &offset);
 }

 return DnsMessage{
 .header = header,
 .questions = questions[0..header.qdcount],
 };
}

The ! in the return type means "this function can fail." The try keyword is equivalent to "if this returns an error, return it from the current function." No exceptions, no error codes that can be forgotten, no errno global state.

Compare with the C equivalent:

// C: Every caller must remember to check the return value
// Nothing stops you from ignoring errors
int parse_dns_message(const uint8_t* buf, size_t len, dns_message_t* out) {
 if (len < 12) return DNS_ERR_TRUNCATED;

 int err = parse_header(buf, &out->header);
 if (err) return err; // Easy to forget this line

 size_t offset = 12;
 for (int i = 0; i < out->header.qdcount; i++) {
 err = parse_question(buf, len, &offset, &out->questions[i]);
 if (err) return err; // And this one
 }

 return 0;
}

In practice, C codebases frequently contain unchecked error returns, a category of bug that Zig eliminates structurally. If a function returns an error union, it must be handled.

C Interop: The Practical Killer Feature

Zig can import C headers directly. Not through FFI bindings, not through a generation step, directly:

const c = @cImport({
 @cInclude("pcap/pcap.h");
 @cInclude("netinet/ip.h");
 @cInclude("netinet/udp.h");
});

fn capturePackets(device: [*:0]const u8) !void {
 var errbuf: [c.PCAP_ERRBUF_SIZE]u8 = undefined;

 const handle = c.pcap_open_live(
 device,
 65535, // snaplen
 1, // promiscuous
 1000, // timeout ms
 &errbuf,
 ) orelse return error.PcapOpenFailed;

 defer c.pcap_close(handle);

 // Set BPF filter for DNS traffic
 var filter: c.struct_bpf_program = undefined;
 if (c.pcap_compile(handle, &filter, "udp port 53", 1, 0) == -1) {
 return error.FilterCompileFailed;
 }
 defer c.pcap_freecode(&filter);

 if (c.pcap_setfilter(handle, &filter) == -1) {
 return error.FilterSetFailed;
 }

 // Capture loop
 while (true) {
 var header: *c.struct_pcap_pkthdr = undefined;
 var data: [*]const u8 = undefined;

 const result = c.pcap_next_ex(handle, &header, &data);
 if (result < 0) break;
 if (result == 0) continue; // timeout

 const packet = data[0..header.caplen];
 processPacket(packet) catch |err| {
 std.log.warn("Packet parse error: {}", .{err});
 };
 }
}

This calls libpcap functions directly. Zig parses the C headers at compile time (using its built-in C parser, not libclang), resolves the types, and generates the correct ABI calls. The orelse and error handling wraps C's null-pointer and error-code patterns into Zig's error system.

Benchmarking a DNS parser implementation reveals the following comparison:

Metric C (gcc -O2) Zig (ReleaseFast)
Binary size 47 KB 52 KB
Parse 1M packets 1.82s 1.79s
Memory (RSS) 2.1 MB 2.3 MB
Lines of code 1,847 1,203

Nearly identical performance, but 35% less code. The extra binary size comes from Zig's panic handler and stack trace support (which can be stripped with --strip).

Where Zig Falls Short

An honest assessment requires acknowledging the current limitations:

The ecosystem is immature. Package management is built-in (build.zig.zon), but the package ecosystem is sparse. Need a JSON parser? There are three, all with different API styles and maturity levels. Need an HTTP client? Zig's standard library has one, but it's still rough. In C, there are decades of battle-tested libraries.

Compile times. Heavy comptime usage can make compilation slow. A DNS parser with aggressive comptime validation takes 4.2 seconds to compile. The equivalent C code compiles in 0.3 seconds. The Zig compiler is single-threaded (the self-hosted compiler is improving this, but it's not there yet).

Documentation. The official documentation is a language reference, not a tutorial. Developers coming from C may spend significant time understanding how allocators work, how to properly handle slices (they're not pointers), and how async works (it's being removed in 0.14, which indicates the language's stability status).

Pre-1.0 churn. Upgrading from 0.12 to 0.13 can require changing 40+ lines across a codebase. Function signatures change, standard library APIs move, and some comptime features get reworked. This is expected for a pre-1.0 language, but it's a real cost.

The Case for Cautious Optimism

Despite these limitations, Zig presents a compelling option for new systems projects, such as custom log aggregators that need to parse millions of structured log lines per second. Several factors support this assessment:

  1. Comptime eliminates entire classes of runtime bugs. Validation that would be assertions in C (or panics in Rust) become compile errors in Zig.

  2. C interop means incremental adoption. Existing C libraries can be called from day one and gradually replaced with Zig implementations.

  3. The error handling model is better than C's. Not as comprehensive as Rust's Result, but more ergonomic. The try keyword alone eliminates dozens of "forgot to check the return code" bugs.

  4. No hidden allocations, no hidden control flow. Every allocation is explicit (an allocator is passed), and there are no operator overloads, implicit conversions, or hidden function calls. Reading Zig code reveals exactly what the CPU will do.

Zig isn't going to replace Rust for memory-safe systems. It isn't going to replace C in legacy codebases. But for new systems code where C-level performance, C interop, and a language that respects developer intelligence without drowning in type theory are priorities, it's among the strongest options available. Comptime alone is worth the learning curve.