A language tour
No hidden control flow. No hidden allocations. No preprocessor. If it happens, you wrote it.
01 — No Hidden Control Flow
Zig's central promise: reading code tells you what the machine does. No operator overloading, no implicit conversions, no destructors running behind your back. Every allocation is explicit; every branch is visible.
"Zig is a general-purpose programming language and toolchain for maintaining robust, optimal and reusable software."
— ziglang.orgconst std = @import("std"); pub fn main() void { // No implicit integer coercion — you must be explicit const x: u32 = 42; const y: u64 = x; // widening is fine const z: u32 = @intCast(y); // narrowing requires explicit cast // Integer overflow is a compile error in debug, checked in safe, // and wrapping only when you say so explicitly const max: u8 = 255; const wrapped = max +% 1; // +% means "wrapping add" — explicit intent std.debug.print("{} {} {}\n", .{ x, z, wrapped }); }
+% is a wrapping addition operator. +| is saturating addition. You choose the overflow behaviour at the call site — the default is "trap in debug, undefined in release."
02 — Comptime
Zig has no generics syntax, no templates, no macros. Instead it has comptime — the ability to run arbitrary Zig code at compile time. Types are values. Functions that take types are generic functions. The language doesn't need a second language.
const std = @import("std"); // Types are values at comptime — this IS the generics system fn Stack(comptime T: type) type { return struct { items: std.ArrayList(T), pub fn push(self: *@This(), val: T) !void { try self.items.append(val); } }; } // Compute fibonacci at compile time — zero runtime cost fn fib(comptime n: u32) u64 { if (n < 2) return n; return fib(n - 1) + fib(n - 2); } const fib40: u64 = fib(40); // evaluated entirely at compile time
Stack(i32) and Stack(f64) are two different types, generated by running the same function at compile time with different arguments. No template specialisation syntax required.
03 — Error Handling
Zig has no exceptions. Errors are part of the return type — the ! prefix marks a function that can fail. The try keyword propagates errors up the call stack. You can't accidentally ignore an error.
const std = @import("std"); const ParseError = error{ InvalidCharacter, Overflow, Empty, }; fn parsePositive(s: []const u8) ParseError!u32 { if (s.len == 0) return error.Empty; return std.fmt.parseInt(u32, s, 10) catch error.InvalidCharacter; } pub fn main() !void { // try propagates error upward — like ? in Rust const n = try parsePositive("42"); // catch handles it inline const m = parsePositive("abc") catch |err| { std.debug.print("Failed: {}\n", .{err}); return; }; }
The return type ParseError!u32 is an error union — a value that is either a ParseError or a u32. The compiler enforces that you handle both cases.
04 — Explicit Allocators
In Zig, allocators are passed explicitly to anything that needs heap memory. There's no global allocator you accidentally use. This makes it trivial to swap a general-purpose allocator for an arena, a pool, or a fixed buffer.
const std = @import("std"); pub fn main() !void { // General-purpose allocator with leak detection var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); // defer = run at scope exit const alloc = gpa.allocator(); // ArrayList knows nothing about memory — you give it an allocator var list = std.ArrayList(u32).init(alloc); defer list.deinit(); try list.append(1); try list.append(2); // Swap in an arena allocator for a phase: O(1) free-all var arena = std.heap.ArenaAllocator.init(alloc); defer arena.deinit(); }
defer runs a statement when the current scope exits — whether by normal return or error. Combined with explicit allocators, it makes resource cleanup deterministic and obvious.
05 — Cross-Compilation
Zig ships with a complete C and C++ cross-compilation toolchain. Without installing anything else, you can compile for any target Zig supports — x86_64, ARM, WASM, RISC-V. Even C projects use zig cc as a drop-in cross-compiler.
const std = @import("std"); // Build script is Zig code — no DSL, no YAML pub fn build(b: *std.Build) void { const targets = .{ "x86_64-linux", "aarch64-macos", "wasm32-wasi", }; inline for (targets) |target_str| { const target = b.resolveTargetQuery( try std.Target.Query.parse(.{ .arch_os_abi = target_str }), ); const exe = b.addExecutable(.{ .name = "myapp-" ++ target_str, .root_source_file = b.path("src/main.zig"), .target = target, }); b.installArtifact(exe); } }
The build system is just Zig code — inline for unrolls the loop at compile time, generating three build steps from a single loop body. No Makefile, no CMake, no shell scripts.
06 — The Whole Picture
In debug mode, Zig detects integer overflow, out-of-bounds, and null dereference at runtime — no UB silent corruption.
test "name" { ... } blocks are first-class. zig test runs them all. No external framework needed.
@cImport reads C headers directly. Call any C library with zero boilerplate — Zig and C speak the same ABI.
Zig links against no libc by default. Your binary is exactly what you put in it — and nothing else.
zig cc is a drop-in C cross-compiler. Many C and C++ projects now use Zig just for building.
Zig is pre-1.0. The best time to watch a language find its footing — and the design is already remarkable.