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 this — self 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
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.