PONY λ M2 Modula-2

C.CodeCompared.To/Zig

An interactive executable cheatsheet comparing C and Zig

C17 (GCC) Zig 0.16.0
Hello World & Build
Hello, World
#include <stdio.h> int main(void) { printf("Hello, World!\n"); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); try stdout.writeStreamingAll(io, "Hello, World!\n"); }
There is no implicit prelude — const std = @import("std"); is always the first line. The pub fn main(init: std.process.Init) !void signature receives an I/O context; the ! prefix creates an error union with void. try propagates any I/O error up to the runtime, which prints it and exits. Zig does not add a newline automatically — \n must be explicit.
Compile & run
// gcc -Wall -Wextra -o hello hello.c && ./hello // With optimisation: // gcc -O2 -o hello hello.c // With sanitisers: // gcc -fsanitize=address,undefined hello.c -o hello
// One-shot run (no output binary): // zig run hello.zig // // Compile to binary: // zig build-exe hello.zig // ./hello // // Release build: // zig build-exe -O ReleaseSafe hello.zig
zig run compiles and executes in one step, similar to a scripting language. -O ReleaseSafe keeps overflow and null-pointer checks but removes debug assertions. -O ReleaseFast maximizes speed (like -O3). There is no separate linker invocation — zig build-exe links the binary directly.
Build system (build.zig)
# Makefile CC = gcc CFLAGS = -Wall -Wextra -O2 TARGET = hello $(TARGET): hello.c $(CC) $(CFLAGS) -o $(TARGET) hello.c clean: rm -f $(TARGET)
// build.zig const std = @import("std"); pub fn build(builder: *std.Build) void { const exe = builder.addExecutable(.{ .name = "hello", .root_source_file = builder.path("hello.zig"), .target = builder.standardTargetOptions(.{}), .optimize = builder.standardOptimizeOption(.{}), }); builder.installArtifact(exe); } // Run: zig build
build.zig replaces Makefiles — it is itself a Zig program. builder.standardTargetOptions enables cross-compilation via -Dtarget=aarch64-linux with no extra toolchain setup. builder.standardOptimizeOption adds -Doptimize=ReleaseSafe flags automatically. There is no separate CMake or autoconf step.
Variables & Types
var and const
#include <stdio.h> int main(void) { int count = 0; // mutable const int limit = 100; // immutable count = 42; // limit = 1; // compile error printf("count=%d limit=%d\n", count, limit); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [64]u8 = undefined; var count: i32 = 0; // mutable const limit: i32 = 100; // immutable count = 42; // limit = 1; // compile error try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "count={d} limit={d}\n", .{ count, limit })); }
In Zig const and var replace all C type keywords — the type follows the name with a colon. All local variables must be used; an unused variable is a compile error (write _ = variable; to explicitly discard it). All variables must be initialized; leaving one unset requires the explicit value undefined.
Type inference
#include <stdio.h> int main(void) { // C has no type inference — type is always explicit int number = 42; double ratio = 3.14; printf("%d %.2f\n", number, ratio); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [64]u8 = undefined; const number = 42; // inferred as comptime_int const ratio = 3.14; // inferred as comptime_float // Runtime variables require explicit types var count: i32 = 0; count += 1; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d} {d:.2} {d}\n", .{ number, ratio, count })); }
Zig infers types for const declarations from their initialiser. Integer and float literals have special compile-time types (comptime_int, comptime_float) that coerce to any compatible concrete type at use. Mutable var declarations usually need an explicit type because the compiler must know the storage size up front.
Integer types
#include <stdio.h> #include <stdint.h> int main(void) { int8_t a = -128; uint8_t b = 255; int32_t c = -2147483648; uint64_t d = 18446744073709551615ULL; printf("%d %u %d %llu\n", a, b, c, d); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [64]u8 = undefined; const a: i8 = -128; const b: u8 = 255; const c: i32 = -2147483648; const d: u64 = 18446744073709551615; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d} {d} {d} {d}\n", .{ a, b, c, d })); }
Zig integer types use a consistent naming scheme: i or u followed by the bit width. Zig also supports arbitrary-width integers up to 65535 bits — u7, i128, and even u1024 are all valid types. There is no int / long ambiguity; widths are always explicit.
Floats & booleans
#include <stdio.h> #include <stdbool.h> int main(void) { float x = 3.14f; double y = 2.71828; _Bool flag = true; if (flag) printf("%.5f %.5f\n", x, y); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [64]u8 = undefined; const x: f32 = 3.14; const y: f64 = 2.71828; const flag: bool = true; if (flag) try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d:.5} {d:.5}\n", .{ x, y })); }
Zig has f16, f32, f64, and f128. Booleans are the type bool with values true and false — there is no implicit conversion from integers to bool. Zero is not false in Zig; using 0 where a bool is expected is a compile error.
No implicit numeric casts
#include <stdio.h> int main(void) { int small = 100; long big = small; // implicit widening — fine in C float ratio = small; // implicit int-to-float — fine in C printf("%ld %.1f\n", big, ratio); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [64]u8 = undefined; const small: i32 = 100; const big: i64 = small; // widening OK: i32 fits in i64 const ratio: f64 = @floatFromInt(small); // explicit cast required try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d} {d:.1}\n", .{ big, ratio })); }
Zig allows implicit widening when the value is guaranteed to fit (e.g. i32 to i64), but never implicit lossy casts. Converting between integers and floats always requires an explicit built-in: @floatFromInt, @intFromFloat, @intCast, or @floatCast. This eliminates a whole class of C bugs caused by silent narrowing.
Undefined / uninitialized values
#include <stdio.h> int main(void) { int x; // uninitialized — undefined behavior if read // printf("%d\n", x); // UB! x = 42; printf("%d\n", x); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; // Zig requires explicit initialisation. // Use 'undefined' to opt in to uninitialized memory (same UB risk as C): var data: [8]u8 = undefined; // intentionally uninitialized // Safe: fill before reading for (&data, 0..) |*byte, i| byte.* = @intCast(i); try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{data[3]})); }
In Zig, leaving a variable without an initialiser is a compile error — you must write = undefined to get C-like uninitialized memory explicitly. In debug builds, undefined is filled with 0xaa bytes, making reads from uninitialised memory obvious in output. In release builds, the bits are indeterminate, same as C.
Strings
String literals
#include <stdio.h> #include <string.h> int main(void) { const char *greeting = "Hello"; size_t length = strlen(greeting); printf("%s has %zu characters\n", greeting, length); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [64]u8 = undefined; const greeting: []const u8 = "Hello"; const length = greeting.len; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{s} has {d} characters\n", .{ greeting, length })); }
A Zig string literal has type *const [N:0]u8 — a pointer to a null-terminated array of bytes — which coerces to []const u8 (a slice). The length is known without scanning for \0: slice.len is a field, not a function. The null terminator still exists in memory for C interop, but Zig code works with the slice, not the pointer.
String comparison
#include <stdio.h> #include <string.h> int main(void) { const char *a = "hello"; const char *b = "hello"; if (strcmp(a, b) == 0) { printf("equal\n"); } return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); const a = "hello"; const b = "hello"; if (std.mem.eql(u8, a, b)) { try stdout.writeStreamingAll(io, "equal\n"); } }
== on slices compares pointer identity, not content — the same footgun as pointer comparison in C. Use std.mem.eql(u8, a, b) to compare byte content. There is also std.mem.order for lexicographic ordering, replacing strcmp for sorting.
String formatting
#include <stdio.h> int main(void) { int count = 3; char buf[64]; snprintf(buf, sizeof(buf), "%d items", count); printf("%s\n", buf); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); const count = 3; var buf: [64]u8 = undefined; const message = try std.fmt.bufPrint(&buf, "{d} items", .{count}); try stdout.writeStreamingAll(io, message); try stdout.writeStreamingAll(io, "\n"); }
std.fmt.bufPrint writes into a caller-provided buffer and returns a slice of the bytes written — no allocation, no null terminator trick. The format string uses {d} for integers, {s} for strings, {any} for any printable type. Format specifiers are checked at compile time; a wrong type is a build error.
Multiline strings
#include <stdio.h> int main(void) { const char *text = "line one\n" "line two\n" "line three\n"; // adjacent string literal concatenation printf("%s", text); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); const text = \\line one \\line two \\line three ; try stdout.writeStreamingAll(io, text); }
Zig multiline strings use the \\ prefix on each line. The leading whitespace up to the \\ is stripped; a newline is appended to each line automatically. This eliminates the need for C's adjacent-literal concatenation trick and avoids manual \n escaping.
Arrays & Slices
Fixed-size arrays
#include <stdio.h> int main(void) { int numbers[5] = {10, 20, 30, 40, 50}; for (int i = 0; i < 5; i++) { printf("%d\n", numbers[i]); } return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; const numbers = [5]i32{ 10, 20, 30, 40, 50 }; for (numbers) |value| { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{value})); } }
Zig array types are written [N]T. The size is part of the type — [5]i32 and [6]i32 are different types, so out-of-bounds access is a compile error when the index is known at compile time. Use [_]i32{ ... } to let the compiler infer the size from the literal.
Slices (fat pointers)
#include <stdio.h> void print_range(const int *arr, size_t length) { for (size_t i = 0; i < length; i++) { printf("%d\n", arr[i]); } } int main(void) { int numbers[] = {10, 20, 30, 40, 50}; print_range(numbers + 1, 3); // elements 1..3 return 0; }
const std = @import("std"); fn print_range(io: std.Io, values: []const i32) !void { const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; for (values) |value| { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{value})); } } pub fn main(init: std.process.Init) !void { const numbers = [5]i32{ 10, 20, 30, 40, 50 }; try print_range(init.io, numbers[1..4]); // elements at index 1, 2, 3 }
A Zig slice []T is a fat pointer: a data pointer plus a length. Passing a slice to a function eliminates the C pattern of passing (ptr, length) separately. Slicing syntax array[start..end] creates a slice of the given range; bounds are checked at runtime in debug mode.
No pointer decay
#include <stdio.h> void takes_pointer(int *arr) { // sizeof(arr) is the pointer size, not the array size — classic footgun printf("sizeof inside fn: %zu\n", sizeof(arr)); } int main(void) { int numbers[] = {1, 2, 3, 4, 5}; printf("sizeof in main: %zu\n", sizeof(numbers)); takes_pointer(numbers); return 0; }
const std = @import("std"); fn takes_slice(io: std.Io, values: []const i32) !void { const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; // values.len is always correct — no decay try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "length inside fn: {d}\n", .{values.len})); } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; const numbers = [5]i32{ 1, 2, 3, 4, 5 }; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "length in main: {d}\n", .{numbers.len})); try takes_slice(io, &numbers); }
C arrays silently decay to pointers when passed to functions, losing their length. In Zig, &array coerces to a slice that carries the length — the length is never lost. Passing []const i32 instead of [*]const i32 (a many-item pointer) makes the length an inseparable part of the value.
Sentinel-terminated arrays
#include <stdio.h> #include <string.h> int main(void) { // C strings are null-terminated char arrays char name[] = "Alice"; printf("length: %zu\n", strlen(name)); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; // String literals are sentinel-terminated: [5:0]u8 const name = "Alice"; // type: *const [5:0]u8 // Coerce to slice for length — no null scan needed const as_slice: []const u8 = name; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "length: {d}\n", .{as_slice.len})); }
Zig string literals have type *const [N:0]u8 — the :0 means the array has a null sentinel at index N. When you coerce to a []const u8 slice, the length is encoded in the type, not read by scanning. The null byte still exists for passing to C functions via .ptr, but Zig code never needs to scan for it.
Pointers
Single-item pointers
#include <stdio.h> int main(void) { int value = 42; int *ptr = &value; *ptr = 100; printf("%d\n", value); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; var value: i32 = 42; const pointer: *i32 = &value; pointer.* = 100; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{value})); }
Zig single-item pointers (*T) are always non-null — the type system guarantees they point to a valid value. Dereference uses .* (postfix) instead of C's prefix *. A nullable pointer is a separate type: ?*T. This makes null pointer dereferences impossible to express accidentally.
Optional (nullable) pointers
#include <stdio.h> #include <stddef.h> int *find_first(int *arr, size_t len, int target) { for (size_t i = 0; i < len; i++) { if (arr[i] == target) return &arr[i]; } return NULL; } int main(void) { int numbers[] = {10, 20, 30}; int *result = find_first(numbers, 3, 20); if (result != NULL) { printf("found: %d\n", *result); } return 0; }
const std = @import("std"); fn find_first(numbers: []i32, target: i32) ?*i32 { for (numbers) |*item| { if (item.* == target) return item; } return null; } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; var numbers = [3]i32{ 10, 20, 30 }; if (find_first(&numbers, 20)) |pointer| { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "found: {d}\n", .{pointer.*})); } }
A nullable pointer in Zig is written ?*T. The if (opt) |value| syntax unwraps it safely — the unwrapped pointer inside the block has type *T, guaranteed non-null. Forgetting to check is a compile error: you cannot dereference a ?*T directly.
Many-item pointers
#include <stdio.h> void print_array(const int *arr, size_t length) { for (size_t i = 0; i < length; i++) { printf("%d\n", arr[i]); } } int main(void) { int numbers[] = {1, 2, 3}; print_array(numbers, 3); return 0; }
const std = @import("std"); fn print_array(io: std.Io, arr: [*]const i32, length: usize) !void { const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; // Must use slice syntax to iterate safely for (arr[0..length]) |value| { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{value})); } } pub fn main(init: std.process.Init) !void { const numbers = [3]i32{ 1, 2, 3 }; // Prefer slices ([]T) over many-item pointers ([*]T) in new code try print_array(init.io, &numbers, numbers.len); }
Zig distinguishes *T (points to exactly one), [*]T (points to unknown count), and []T (slice: pointer + known length). A C int * maps to [*]i32. In new Zig code, prefer slices ([]T) — they carry their own length. Use [*]T mainly when interoperating with C APIs.
Control Flow
if as an expression
#include <stdio.h> int main(void) { int score = 75; const char *grade; // C has ternary but no if-expression grade = (score >= 60) ? "pass" : "fail"; printf("%s\n", grade); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); const score = 75; const grade = if (score >= 60) "pass" else "fail"; try stdout.writeStreamingAll(io, grade); try stdout.writeStreamingAll(io, "\n"); }
In Zig, if is an expression — it produces a value. The ternary operator ?: does not exist; use if (cond) a else b instead. Both branches must produce the same type, which the compiler verifies. Block expressions using { ... } can also produce values via break.
while loops
#include <stdio.h> int main(void) { int i = 0; while (i < 5) { printf("%d\n", i); i++; } return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; var i: usize = 0; while (i < 5) : (i += 1) { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{i})); } }
Zig while has an optional "continue expression" in : (expr) syntax after the condition. This runs after each iteration, including when continue is used — unlike putting the increment at the bottom of the loop body, which would be skipped by a continue statement.
for loops over slices
#include <stdio.h> int main(void) { int numbers[] = {10, 20, 30, 40, 50}; int total = 0; for (int i = 0; i < 5; i++) { total += numbers[i]; } printf("total: %d\n", total); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; const numbers = [5]i32{ 10, 20, 30, 40, 50 }; var total: i32 = 0; for (numbers) |value| { total += value; } try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "total: {d}\n", .{total})); }
Zig for iterates over slices and arrays directly — no index variable, no bounds arithmetic. Use for (items, 0..) |item, index| to get both the value and its index. For raw C-style counting loops, use while with a counter variable instead.
switch (exhaustive)
#include <stdio.h> int main(void) { int day = 3; switch (day) { case 1: printf("Monday\n"); break; case 2: printf("Tuesday\n"); break; case 3: printf("Wednesday\n"); break; default: printf("other\n"); break; // Forgetting a case is not a compile error in C } return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); const day = 3; const name = switch (day) { 1 => "Monday", 2 => "Tuesday", 3 => "Wednesday", else => "other", }; try stdout.writeStreamingAll(io, name); try stdout.writeStreamingAll(io, "\n"); }
Zig switch requires every possible value to be covered — missing a case is a compile error. There is no fall-through; each arm is independent. When switching on an enum, the else branch is only needed if the enum has unknown variants (e.g. from C). switch is also an expression that can return a value.
Labeled blocks & break with value
#include <stdio.h> int main(void) { int result = 0; // C has no block-as-expression; use a helper function or ternary for (int i = 0; i < 10; i++) { if (i * i > 50) { result = i; break; } } printf("%d\n", result); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; const result = search: { var i: i32 = 0; while (i < 10) : (i += 1) { if (i * i > 50) break :search i; } break :search 0; }; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{result})); }
Zig blocks can be labeled and used as expressions. break :label value exits the labeled block and produces the given value. This eliminates the C pattern of assigning to a variable inside a loop just to use the value afterward. Every code path in the block must produce a value of the same type.
Functions
Function definitions
#include <stdio.h> int add(int a, int b) { return a + b; } int square(int n) { return n * n; } int main(void) { printf("%d %d\n", add(3, 4), square(5)); return 0; }
const std = @import("std"); fn add(a: i32, b: i32) i32 { return a + b; } fn square(number: i32) i32 { return number * number; } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d} {d}\n", .{ add(3, 4), square(5) })); }
Zig function parameters are always name: Type, never just a type. Parameters are immutable by default — pass a pointer to modify the caller's value. Functions are not first-class values by default; use function pointers (*const fn(i32) i32) when you need to store or pass them.
Multiple return values
#include <stdio.h> // C has no multiple return; use output parameters void min_max(const int *arr, int len, int *min, int *max) { *min = *max = arr[0]; for (int i = 1; i < len; i++) { if (arr[i] < *min) *min = arr[i]; if (arr[i] > *max) *max = arr[i]; } } int main(void) { int numbers[] = {3, 1, 4, 1, 5, 9}; int low, high; min_max(numbers, 6, &low, &high); printf("min=%d max=%d\n", low, high); return 0; }
const std = @import("std"); fn min_max(numbers: []const i32) struct { min: i32, max: i32 } { var low = numbers[0]; var high = numbers[0]; for (numbers[1..]) |value| { if (value < low) low = value; if (value > high) high = value; } return .{ .min = low, .max = high }; } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; const numbers = [6]i32{ 3, 1, 4, 1, 5, 9 }; const result = min_max(&numbers); try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "min={d} max={d}\n", .{ result.min, result.max })); }
Zig functions can return an anonymous struct literal (.{ .field = value }). The return type can be written inline as struct { min: i32, max: i32 }. This is cleaner than C's output-pointer convention and avoids heap allocation unlike returning a named struct by pointer.
Function pointers & callbacks
#include <stdio.h> int apply(int x, int (*operation)(int)) { return operation(x); } int double_it(int n) { return n * 2; } int triple_it(int n) { return n * 3; } int main(void) { printf("%d %d\n", apply(5, double_it), apply(5, triple_it)); return 0; }
const std = @import("std"); fn apply(x: i32, operation: *const fn (i32) i32) i32 { return operation(x); } fn double_it(number: i32) i32 { return number * 2; } fn triple_it(number: i32) i32 { return number * 3; } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d} {d}\n", .{ apply(5, &double_it), apply(5, &triple_it) })); }
Zig function pointer types are written *const fn(ArgType) ReturnType. Pass a function's address with &function_name. For higher-order programming with closures that capture state, Zig uses structs with a method — there are no closures in the C sense.
Structs & Methods
Struct definition & initialization
#include <stdio.h> typedef struct { float x; float y; } Point; int main(void) { Point origin = {0.0f, 0.0f}; Point corner = {.x = 3.0f, .y = 4.0f}; printf("(%.1f, %.1f)\n", corner.x, corner.y); return 0; }
const std = @import("std"); const Point = struct { x: f32, y: f32, }; pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; const origin = Point{ .x = 0, .y = 0 }; const corner = Point{ .x = 3, .y = 4 }; _ = origin; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "({d:.1}, {d:.1})\n", .{ corner.x, corner.y })); }
Zig struct literals always use named fields (.x = value). Positional initialisation like C's { 3.0f, 4.0f } is not allowed — every field must be named, which prevents the common C bug of swapping struct members and silently getting wrong values.
Methods on structs
#include <stdio.h> #include <math.h> typedef struct { float x; float y; } Point; float point_distance(Point p) { return sqrtf(p.x * p.x + p.y * p.y); } int main(void) { Point corner = {3.0f, 4.0f}; printf("%.1f\n", point_distance(corner)); return 0; }
const std = @import("std"); const Point = struct { x: f32, y: f32, fn distance(self: Point) f32 { return std.math.sqrt(self.x * self.x + self.y * self.y); } }; pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; const corner = Point{ .x = 3, .y = 4 }; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d:.1}\n", .{corner.distance()})); }
Zig methods are functions defined inside a struct block whose first parameter is named self (by convention). They are called with dot syntax: value.method(). There is no hidden thisself is an ordinary parameter that can be Point, *Point, or *const Point depending on whether the method mutates.
Packed structs & bit fields
#include <stdio.h> #include <stdint.h> typedef struct __attribute__((packed)) { uint8_t version : 4; uint8_t flags : 4; uint16_t length; } Header; int main(void) { Header h = {.version = 2, .flags = 5, .length = 1024}; printf("v=%d f=%d len=%d\n", h.version, h.flags, h.length); return 0; }
const std = @import("std"); const Header = packed struct { version: u4, flags: u4, length: u16, }; pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; const header = Header{ .version = 2, .flags = 5, .length = 1024 }; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "v={d} f={d} len={d}\n", .{ header.version, header.flags, header.length })); }
packed struct guarantees bit-level layout with no padding and no compiler-specific attributes needed. Zig's arbitrary-width integers (u4, u12, etc.) eliminate the : N bit-field syntax. The struct size in bytes is exactly the sum of all field bit widths divided by 8.
Enums & Tagged Unions
Enumerations
#include <stdio.h> typedef enum { RED, GREEN, BLUE } Color; int main(void) { Color favorite = GREEN; switch (favorite) { case RED: printf("red\n"); break; case GREEN: printf("green\n"); break; case BLUE: printf("blue\n"); break; } return 0; }
const std = @import("std"); const Color = enum { red, green, blue }; pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); const favorite = Color.green; const name = switch (favorite) { .red => "red", .green => "green", .blue => "blue", }; try stdout.writeStreamingAll(io, name); try stdout.writeStreamingAll(io, "\n"); }
Zig enum values are namespaced: Color.green or just .green when the type is inferred. The switch on an enum is exhaustive — omitting any variant is a compile error; no else needed when all variants are covered. Enum values are not integers by default; use @intFromEnum to convert.
Tagged unions
#include <stdio.h> typedef enum { SHAPE_CIRCLE, SHAPE_RECT } ShapeTag; typedef struct { ShapeTag tag; union { float radius; struct { float width; float height; } rect; }; } Shape; int main(void) { Shape circle = {SHAPE_CIRCLE, .radius = 5.0f}; // No compiler enforcement that tag matches the union field read printf("radius: %.1f\n", circle.radius); return 0; }
const std = @import("std"); const Shape = union(enum) { circle: f32, rect: struct { width: f32, height: f32 }, }; pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; const circle = Shape{ .circle = 5.0 }; switch (circle) { .circle => |radius| try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "radius: {d:.1}\n", .{radius})), .rect => |r| try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "rect: {d:.1}x{d:.1}\n", .{ r.width, r.height })), } }
union(enum) is a tagged union where the tag is the enum itself — there is no separate tag field to keep in sync. Pattern matching with switch is exhaustive and gives you the payload with |value|. Accessing the wrong union field at runtime panics in debug mode instead of silently reading garbage bytes.
Optionals
Optional values (?T)
#include <stdio.h> #include <stddef.h> // C convention: return -1 as a sentinel for "not found" int find_index(const int *arr, int len, int target) { for (int i = 0; i < len; i++) { if (arr[i] == target) return i; } return -1; // caller must know -1 means "not found" } int main(void) { int numbers[] = {10, 20, 30}; int idx = find_index(numbers, 3, 20); if (idx >= 0) printf("found at %d\n", idx); return 0; }
const std = @import("std"); fn find_index(numbers: []const i32, target: i32) ?usize { for (numbers, 0..) |value, index| { if (value == target) return index; } return null; // type says "this might be absent" } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; const numbers = [3]i32{ 10, 20, 30 }; if (find_index(&numbers, 20)) |index| { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "found at {d}\n", .{index})); } }
?T is the optional type — it can hold a value of type T or null. The return type in the signature makes the "might be absent" contract explicit, unlike C's sentinel convention (-1, NULL, SIZE_MAX) which is documented only in comments. The caller is forced to check before using the value.
orelse — optional with default
#include <stdio.h> #include <stdlib.h> const char *get_env(const char *key) { const char *value = getenv(key); return value != NULL ? value : "default"; } int main(void) { printf("%s\n", get_env("MISSING_KEY")); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); // Use orelse to supply a default without unwrapping const port_str: ?[]const u8 = null; // simulating absent env var const port = port_str orelse "8080"; try stdout.writeStreamingAll(io, port); try stdout.writeStreamingAll(io, "\n"); }
orelse is the optional default operator — opt orelse fallback returns the wrapped value if present, or evaluates fallback otherwise. It replaces the C ternary pattern ptr != NULL ? ptr : default. The right-hand side of orelse can also be a return or break, making early-exit idioms concise.
Unwrapping optionals
#include <stdio.h> #include <stddef.h> int *find_value(int *arr, int len, int target) { for (int i = 0; i < len; i++) { if (arr[i] == target) return &arr[i]; } return NULL; } int main(void) { int data[] = {1, 2, 3}; int *found = find_value(data, 3, 2); if (found) *found *= 10; // safe check for (int i = 0; i < 3; i++) printf("%d\n", data[i]); return 0; }
const std = @import("std"); fn find_value(values: []i32, target: i32) ?*i32 { for (values) |*item| { if (item.* == target) return item; } return null; } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [8]u8 = undefined; var data = [3]i32{ 1, 2, 3 }; if (find_value(&data, 2)) |pointer| { pointer.* *= 10; // guaranteed non-null inside the block } for (data) |value| { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{value})); } }
if (optional) |value| { ... } unwraps the optional — inside the block, value has type T, not ?T. The block is skipped entirely if the optional is null. You can also use while (optional) |value| to loop until null, which is useful for iterators that return null when exhausted.
Error Handling
Error sets
#include <stdio.h> #include <errno.h> #include <string.h> // C: errors are ints, errno, or return codes — no type safety int open_port(int port) { if (port < 1 || port > 65535) { errno = EINVAL; return -1; } return port; } int main(void) { int result = open_port(99999); if (result < 0) { printf("error: %s\n", strerror(errno)); } return 0; }
const std = @import("std"); const PortError = error{ InvalidPort, PermissionDenied, }; fn open_port(port: u16) PortError!u16 { if (port < 1024) return PortError.PermissionDenied; return port; } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [64]u8 = undefined; const result = open_port(80); if (result) |port| { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "opened port {d}\n", .{port})); } else |err| { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "error: {s}\n", .{@errorName(err)})); } }
An error set is a type: error{ A, B, C }. The return type PortError!u16 is an error union — the function returns either an error from PortError or a valid u16. @errorName(err) converts an error value to its name string. Error values are not integers — you cannot compare them to -1 or ignore them silently.
try — propagate errors
#include <stdio.h> #include <stdlib.h> // C: must check and propagate manually at every call site char *read_config(const char *path) { FILE *file = fopen(path, "r"); if (!file) return NULL; fseek(file, 0, SEEK_END); long size = ftell(file); rewind(file); char *buf = malloc(size + 1); if (!buf) { fclose(file); return NULL; } fread(buf, 1, size, file); buf[size] = '\0'; fclose(file); return buf; } int main(void) { char *cfg = read_config("config.txt"); if (!cfg) { printf("failed\n"); return 1; } printf("%s\n", cfg); free(cfg); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); // try propagates any error returned by the expression const file = std.fs.cwd().openFile("config.txt", .{}) catch |err| { try stdout.writeStreamingAll(io, @errorName(err)); try stdout.writeStreamingAll(io, "\n"); return; }; defer file.close(); var buf: [256]u8 = undefined; const bytes_read = try file.readAll(&buf); try stdout.writeStreamingAll(io, buf[0..bytes_read]); }
try expr is shorthand for expr catch |err| return err — it unwraps the success value or propagates the error up the call stack. This makes the "happy path" readable without swallowing errors. catch |err| { ... } handles specific errors inline. defer file.close() guarantees cleanup regardless of which path exits.
errdefer — cleanup on error
#include <stdio.h> #include <stdlib.h> // C: must remember to free on every error path char *make_greeting(const char *name) { char *buffer = malloc(64); if (!buffer) return NULL; int written = snprintf(buffer, 64, "Hello, %s!", name); if (written < 0) { free(buffer); // easy to forget return NULL; } return buffer; } int main(void) { char *greeting = make_greeting("World"); if (greeting) { printf("%s\n", greeting); free(greeting); } return 0; }
const std = @import("std"); fn make_greeting(allocator: std.mem.Allocator, name: []const u8) ![]u8 { const greeting = try allocator.alloc(u8, name.len + 8); // "Hello, " + name + "!" errdefer allocator.free(greeting); // runs only if the next line errors _ = std.fmt.bufPrint(greeting, "Hello, {s}!", .{name}) catch |err| return err; return greeting; } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var debug_alloc: std.heap.DebugAllocator(.{}) = .init; defer _ = debug_alloc.deinit(); const allocator = debug_alloc.allocator(); const greeting = try make_greeting(allocator, "World"); defer allocator.free(greeting); try stdout.writeStreamingAll(io, greeting); try stdout.writeStreamingAll(io, "\n"); }
errdefer runs its expression only when the enclosing function returns an error — not on normal return. This eliminates the C pattern of duplicating cleanup code on every error path. defer runs unconditionally. Together, they replace C's goto cleanup pattern cleanly.
Memory & Allocators
Allocator interface (no global malloc)
#include <stdio.h> #include <stdlib.h> #include <string.h> int main(void) { // malloc/free are global — no way to swap the allocator char *buf = malloc(32); if (!buf) { perror("malloc"); return 1; } strcpy(buf, "hello from heap"); printf("%s\n", buf); free(buf); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); // The allocator is a parameter — any allocator works here var debug_alloc: std.heap.DebugAllocator(.{}) = .init; defer _ = debug_alloc.deinit(); const allocator = debug_alloc.allocator(); const buf = try allocator.alloc(u8, 32); defer allocator.free(buf); const message = try std.fmt.bufPrint(buf, "hello from heap", .{}); try stdout.writeStreamingAll(io, message); try stdout.writeStreamingAll(io, "\n"); }
There is no global malloc in idiomatic Zig. Functions that allocate receive an std.mem.Allocator parameter — the caller decides which allocator to use. Swapping from a general-purpose allocator to an arena or a fixed-buffer allocator requires only changing the call site, not the library code.
defer for guaranteed cleanup
#include <stdio.h> #include <stdlib.h> int process(void) { char *buf = malloc(128); if (!buf) return -1; FILE *file = fopen("data.txt", "r"); if (!file) { free(buf); // must remember to free before every return return -1; } // ... work ... fclose(file); free(buf); return 0; } int main(void) { if (process() < 0) printf("error\n"); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var debug_alloc: std.heap.DebugAllocator(.{}) = .init; defer _ = debug_alloc.deinit(); const allocator = debug_alloc.allocator(); const buf = try allocator.alloc(u8, 128); defer allocator.free(buf); // guaranteed, no matter how we exit // Simulate work without actually opening a file @memset(buf, 'A'); try stdout.writeStreamingAll(io, buf[0..1]); try stdout.writeStreamingAll(io, "\n"); }
defer executes at the end of the enclosing scope — whether the function returns normally, via try error propagation, or via an explicit return. Multiple defers execute in reverse order (LIFO). This replaces C's goto cleanup pattern and eliminates the need to duplicate cleanup on every error path.
Arena allocator
#include <stdio.h> #include <stdlib.h> // C: manual arena — bump a pointer, free the whole block at once typedef struct { char *base; size_t used; size_t cap; } Arena; char *arena_alloc(Arena *a, size_t n) { if (a->used + n > a->cap) return NULL; char *ptr = a->base + a->used; a->used += n; return ptr; } int main(void) { char backing[1024]; Arena arena = {backing, 0, sizeof(backing)}; char *a = arena_alloc(&arena, 16); char *b = arena_alloc(&arena, 32); (void)a; (void)b; // Free all at once by discarding the arena printf("used %zu bytes\n", arena.used); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; var backing: [1024]u8 = undefined; var fba = std.heap.FixedBufferAllocator.init(&backing); const arena = fba.allocator(); const buf_a = try arena.alloc(u8, 16); const buf_b = try arena.alloc(u8, 32); _ = buf_a; _ = buf_b; // Free everything at once — reset the fixed buffer fba.reset(); try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "reset done, {d} bytes free\n", .{backing.len})); }
Zig's FixedBufferAllocator is a bump allocator backed by a stack array — no heap involved. std.heap.ArenaAllocator wraps any allocator and lets you free all allocations at once with arena.deinit(). Switching strategies is a one-line change at the call site; all code using the Allocator interface works unchanged.
Dynamic arrays (ArrayList)
#include <stdio.h> #include <stdlib.h> int main(void) { size_t count = 0; size_t capacity = 4; int *items = malloc(capacity * sizeof(int)); for (int i = 0; i < 8; i++) { if (count == capacity) { capacity *= 2; items = realloc(items, capacity * sizeof(int)); } items[count++] = i * i; } for (size_t i = 0; i < count; i++) printf("%d\n", items[i]); free(items); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; var debug_alloc: std.heap.DebugAllocator(.{}) = .init; defer _ = debug_alloc.deinit(); const allocator = debug_alloc.allocator(); var items: std.ArrayList(i32) = .empty; defer items.deinit(allocator); for (0..8) |i| { try items.append(allocator, @intCast(i * i)); } for (items.items) |value| { try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{value})); } }
std.ArrayList(T) is the standard growable array. append can fail with an out-of-memory error, so it requires try. In Zig 0.16.0, the allocator is passed per-call rather than stored in the list. Access the underlying slice via .items. defer items.deinit(allocator) frees the backing storage when the scope exits.
Comptime
comptime constants
#include <stdio.h> // C: use #define or enum for compile-time constants #define MAX_ITEMS 64 #define BUFFER_SIZE (MAX_ITEMS * sizeof(int)) // Or typed enum (avoids macro pitfalls): // enum { MAX_ITEMS = 64 }; int main(void) { int buffer[MAX_ITEMS]; printf("items=%d bytes=%zu\n", MAX_ITEMS, sizeof(buffer)); return 0; }
const std = @import("std"); const max_items: usize = 64; const buffer_size = max_items * @sizeOf(i32); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [64]u8 = undefined; var buffer: [max_items]i32 = undefined; _ = &buffer; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "items={d} bytes={d}\n", .{ max_items, buffer_size })); }
Zig has no preprocessor — #define does not exist. Top-level const declarations are compile-time constants. Any expression that can be evaluated at compile time is valid in a const initialiser: arithmetic, @sizeOf, @typeInfo, and more. Constants are typed, so typos in type produce a useful error.
Generics via comptime
#include <stdio.h> // C: generics via void* (unsafe) or macros (no type checking) void swap(void *a, void *b, size_t size) { char tmp[256]; __builtin_memcpy(tmp, a, size); __builtin_memcpy(a, b, size); __builtin_memcpy(b, tmp, size); } int main(void) { int x = 1, y = 2; swap(&x, &y, sizeof(int)); printf("%d %d\n", x, y); return 0; }
const std = @import("std"); fn swap(comptime T: type, a: *T, b: *T) void { const tmp = a.*; a.* = b.*; b.* = tmp; } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; var x: i32 = 1; var y: i32 = 2; swap(i32, &x, &y); try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d} {d}\n", .{ x, y })); }
comptime T: type is a compile-time parameter that accepts a type. Zig generates a separate concrete function for each type used — like C++ templates but with no separate template syntax. The compiler catches type mismatches at the call site, unlike C's void* approach. Types are first-class values at compile time.
Compile-time if (conditional compilation)
#include <stdio.h> // C: conditional compilation via preprocessor #ifdef DEBUG #define LOG(msg) printf("DEBUG: %s\n", msg) #else #define LOG(msg) #endif int main(void) { LOG("starting"); printf("running\n"); return 0; }
const std = @import("std"); const debug_mode = false; // change to true to enable pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); if (comptime debug_mode) { try stdout.writeStreamingAll(io, "DEBUG: starting\n"); } try stdout.writeStreamingAll(io, "running\n"); }
if (comptime condition) is evaluated at compile time — the dead branch is not compiled at all, not merely optimized away. This replaces #ifdef with type-safe, readable Zig code. The condition must be a comptime-known value; the compiler enforces this rather than relying on the preprocessor.
@TypeOf and type reflection
#include <stdio.h> // C: no runtime type information; typeof is a GCC extension int main(void) { int x = 42; __typeof__(x) y = x * 2; // GCC-specific printf("%d\n", y); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; const x: i32 = 42; const y: @TypeOf(x) = x * 2; // type inferred at compile time // @typeInfo gives a tagged union with all type details const info = @typeInfo(@TypeOf(x)); try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d} is i{d}\n", .{ y, info.int.bits })); }
@TypeOf(expr) returns the type of an expression as a compile-time value. @typeInfo(T) returns a std.builtin.Type tagged union — you can inspect fields, enum members, function signatures, and more at compile time. This replaces C's _Generic and GCC's __typeof__ with a uniform, portable mechanism.
C Interop
Importing C headers
// C calling its own standard library — trivial #include <stdio.h> #include <math.h> int main(void) { printf("sqrt(2) = %.6f\n", sqrt(2.0)); return 0; }
// Calling C's math.h from Zig const std = @import("std"); const c = @cImport({ @cInclude("math.h"); }); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; const result = c.sqrt(2.0); try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "sqrt(2) = {d:.6}\n", .{result})); }
@cImport runs the C preprocessor on the listed headers and makes all their declarations available as Zig types. There is no FFI binding layer — Zig calls C functions directly with zero overhead. The Zig build system handles -lm linking automatically when math.h functions are used.
Exporting Zig functions to C
// C side (consumer of the Zig library): // #include "mylib.h" // extern int zig_add(int a, int b); // int result = zig_add(3, 4);
// Zig side — export with C calling convention const std = @import("std"); export fn zig_add(a: c_int, b: c_int) c_int { return a + b; } // For standalone test: pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{zig_add(3, 4)})); }
export fn makes a Zig function visible to C with its C ABI. The C-compatible integer types are c_int, c_long, c_size_t, etc. — Zig maps them to the correct width for the target platform. This lets you replace individual C files in a project with Zig implementations incrementally.
Translating C code to Zig
// Translate a C file to Zig automatically: // zig translate-c input.c > output.zig // // Original C: #include <stdio.h> int main(void) { int values[] = {1, 2, 3}; for (int i = 0; i < 3; i++) { printf("%d\n", values[i]); } return 0; }
// Rough equivalent of what zig translate-c produces: const std = @import("std"); pub const printf = std.c.printf; pub fn main() void { var values = [3]c_int{ 1, 2, 3 }; var i: c_int = 0; while (i < 3) : (i += 1) { _ = printf("%d\n", values[@intCast(i)]); } }
zig translate-c converts a C file to Zig source, useful as a starting point for migration. The output uses C-style idioms initially — the idea is to then refine toward idiomatic Zig. Zig can also directly compile C files: zig cc file.c works as a drop-in replacement for gcc, enabling gradual adoption.
Gotchas for C Programmers
Integer overflow is checked
#include <stdio.h> #include <stdint.h> int main(void) { uint8_t counter = 255; counter++; // wraps silently to 0 in C (defined for unsigned) printf("%d\n", counter); // Signed overflow is undefined behavior in C: // int big = INT_MAX; big++; // UB! return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; // Zig: overflow panics in debug, traps in ReleaseSafe // Use wrapping operators when you want C-like wrapping: var counter: u8 = 255; counter +%= 1; // wrapping add: 255 + 1 = 0 try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{counter})); // Or use saturating arithmetic: var saturated: u8 = 255; saturated +|= 1; // saturating: stays at 255 try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{saturated})); }
In debug and ReleaseSafe builds, integer overflow panics at runtime. Wrapping arithmetic uses explicit operators: +%, -%, *%. Saturating arithmetic uses +|, -|, *|. This eliminates undefined behavior for signed overflow and makes intentional wrapping explicit — a reader knows you chose wrapping on purpose.
Null pointer dereference is impossible by type
#include <stdio.h> #include <stdlib.h> int main(void) { int *ptr = NULL; // C: nothing stops you — crash at runtime // printf("%d\n", *ptr); // Must check manually: if (ptr != NULL) { printf("%d\n", *ptr); } else { printf("null\n"); } return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); // *i32 cannot be null — the type forbids it // ?*i32 is the nullable variant; must be unwrapped before use const maybe: ?*i32 = null; // Cannot write: const value = maybe.*; // compile error if (maybe) |pointer| { var buf: [16]u8 = undefined; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d}\n", .{pointer.*})); } else { try stdout.writeStreamingAll(io, "null\n"); } }
A Zig *T pointer can never be null — the type system enforces this at compile time. Nullable pointers are a different type: ?*T. Before you can dereference a ?*T, you must unwrap it with if, orelse, or .? (force-unwrap, panics if null). Accidentally dereferencing null is a category of bug that does not exist in Zig.
Unused variables are a compile error
#include <stdio.h> int main(void) { int result = 42; // result is never used — compiler may warn, but it still compiles printf("done\n"); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); const result = 42; _ = result; // explicit discard — tells the reader this is intentional try stdout.writeStreamingAll(io, "done\n"); }
Zig upgrades unused-variable warnings to hard errors. To discard a value intentionally, assign it to _: _ = result;. This makes the intent visible in code review — a reader sees you chose to ignore the value rather than wondering if you forgot. The same applies to unused function parameters.
No hidden control flow
// C++ (for contrast) allows operator overloading: // MyInt operator+(MyInt a, MyInt b) { // throw std::runtime_error("surprise!"); // hidden exception // } // // C itself has no hidden control flow — but setjmp/longjmp // and signal handlers can surprise you. #include <stdio.h> int main(void) { printf("C has no hidden control flow either.\n"); return 0; }
const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); // No operator overloading, no exceptions, no destructors, // no implicit conversions — what you see is what runs. // The only "hidden" effect is defer/errdefer, which is explicit. const items = [3]i32{ 1, 2, 3 }; var total: i32 = 0; for (items) |value| total += value; // just addition, nothing else var buf: [32]u8 = undefined; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "total: {d}\n", .{total})); }
Zig's design explicitly avoids hidden control flow: no operator overloading, no exceptions, no implicit constructors or destructors, no implicit type conversions. Every function call is visible in the source. defer and errdefer are the only constructs that defer execution, and they are always written at the call site — never injected by a type.
No preprocessor — no macros
#include <stdio.h> // C macros: powerful but error-prone #define SQUARE(x) ((x) * (x)) #define MAX(a, b) ((a) > (b) ? (a) : (b)) int main(void) { int result = SQUARE(3 + 1); // OK with parens int bigger = MAX(3, 5); printf("%d %d\n", result, bigger); return 0; }
const std = @import("std"); // Zig: inline functions instead of macros inline fn square(x: i32) i32 { return x * x; } inline fn max(a: i32, b: i32) i32 { return if (a > b) a else b; } pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [16]u8 = undefined; const result = square(3 + 1); const bigger = max(3, 5); try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "{d} {d}\n", .{ result, bigger })); }
Zig has no preprocessor. Macros are replaced by inline fn (always inlined, type-checked), comptime functions (run at compile time), and generic functions (comptime T: type). All of these are real Zig code with types, scopes, and debugger visibility — unlike C macros, which are text substitutions that the compiler never sees.
Safety modes
// C: one mode — optimisation level only // gcc -O0 = no optimisation, some runtime checks (ASan/UBSan optional) // gcc -O2 = optimized, UB is "optimized away" dangerously // gcc -O3 = aggressive, UB exploited for speed // // To get runtime safety checks in C: // gcc -fsanitize=address,undefined -g program.c
// Zig: four distinct build modes // zig build-exe -O Debug (default: all checks, slow) // zig build-exe -O ReleaseSafe (fast + overflow/null checks kept) // zig build-exe -O ReleaseSmall (size-optimized, checks removed) // zig build-exe -O ReleaseFast (fastest, checks removed — like -O3) const std = @import("std"); pub fn main(init: std.process.Init) !void { const io = init.io; const stdout = std.Io.File.stdout(); var buf: [32]u8 = undefined; // builtin.mode is known at comptime const mode = @import("builtin").mode; try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buf, "mode: {s}\n", .{@tagName(mode)})); }
Zig's ReleaseSafe mode is a sweet spot that does not exist in C: full speed with integer overflow, out-of-bounds, and null-dereference checks still active. In C, enabling -fsanitize is a separate step that many teams skip. In Zig, safety is opt-out, not opt-in — and the build system makes the choice explicit.