Bake a CRC Table into Your Zig Binary with comptime
Compute a CRC lookup table at compile time so your binary ships only the finished bytes, with no runtime initialization.
04 Mar 2026, 05:43 UTC

The problem: tables that initialize themselves
Many embedded and networking programs need a CRC lookup table. The straightforward approach is to declare a global array and fill it in a startup function. That works, but it costs flash, adds startup code, and gives the runtime something to get wrong. Another common approach is to generate a header file with a script and check the generated file into version control. That separates the table from the code that uses it and creates a drift risk when the polynomial changes.
Zig avoids both problems with comptime. Instead of a preprocessor, macros, or templates, Zig lets you run ordinary Zig code while the compiler builds your program. The result is a constant in the binary's data section: no init function, no script, no drift.
How comptime works
In Zig, comptime is not a separate language. It is the same if, for, switch, and function calls you use at runtime, evaluated by the compiler when the values are known. A parameter marked comptime acts like a generic: each distinct value produces a separate specialization. The standard library uses this pattern for containers such as ArrayList.
Comptime code runs in a subset of Zig. You cannot open files, allocate with a general-purpose allocator, or touch runtime state. What you can do is compute constants, inspect types with @typeInfo, and emit fixed arrays.
Worked example: a CRC8 table
This snippet computes a 256-entry CRC8 table (polynomial 0x07) during compilation and exposes it as a package-level constant. Save it as crc_table.zig.
const std = @import("std");
/// Compute a CRC8 lookup table at compile time.
comptime fn crc8Table() [256]u8 {
var table: [256]u8 = undefined;
var i: u16 = 0;
while (i < 256) : (i += 1) {
var crc: u8 = @intCast(i);
var j: u32 = 0;
while (j < 8) : (j += 1) {
crc = if ((crc & 0x80) != 0, (crc << 1) ^ 0x07, crc << 1);
}
table[@intCast(i)] = crc;
}
return table;
}
/// Inspect values during compilation. Remove before shipping if desired.
comptime {
@compileLog("table[0] = ", crc8Table()[0]);
@compileLog("table[255] = ", crc8Table()[255]);
}
/// The binary contains only these bytes. No startup loop fills them.
pub const CRC8_TABLE = crc8Table();
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.print("CRC8 of 'A' = {d}\n", .{crc8Byte('A')});
}
comptime fn crc8Byte(input: u8) u8 {
var crc: u8 = 0;
crc = crc ^ input;
return CRC8_TABLE[crc];
}
Run this from a terminal in the directory containing the file:
# Where: your terminal, in the project directory
# Permissions: none special; an ordinary user is sufficient
# Placeholder: replace crc_table.zig with your actual file name
zig build-exe crc_table.zig
Expected check: the compiler emits an executable. If you run it, it prints the CRC8 of the character 'A'. To confirm the table is baked in, inspect the executable with a disassembler or xxd and look for the 256-byte constant in the read-only data section. Exact bytes and addresses depend on your toolchain, so verify locally rather than comparing to a published listing.
@compileLog prints during compilation. It is a useful diagnostic when a comptime calculation does not produce the value you expect. If the computation fails, Zig reports the error at the build step, not at runtime.
Trade-offs and limits
- Build time: the compiler runs the table logic for every build. A 256-entry table is negligible, but heavy comptime work slows compilation.
- Binary size: each distinct specialization of a comptime-generic function adds code. One CRC table costs 256 bytes; many specialized variants can bloat the binary.
- Diagnostics: comptime errors can produce long traces. Zig has improved this, but a deeply nested failure still requires reading the compile error carefully.
- Language subset: no file I/O, no network, and no arbitrary runtime state inside comptime. If your table depends on a file at build time, you need a build script (
build.zig) instead.
Zig is pre-1.0, and the standard library changes between 0.x releases. Check zig version and the language reference bundled with your toolchain before adapting this snippet to a newer release.
Explicit allocators: a related habit
Zig functions that allocate usually take an std.mem.Allocator parameter. This makes every allocation visible at the call site and lets you use per-request arenas. It is not part of the CRC example, which needs no allocation, but it is the same philosophy: be explicit about what happens and when.
Closing
If you maintain a lookup table today, try moving its generation into a comptime fn. The binary gets a static constant, your build loses a code-generation step, and the table stays in sync with the code that uses it. Compile the example, inspect the output, and decide whether the build-time cost is worth the runtime simplicity for your project.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.