What is Zig?
A general-purpose, compiled systems language designed for robustness, optimality, and clarity — without hidden control flow or hidden memory allocations.
Why Zig?
Zig occupies a similar space to C — low-level, manual memory management, no garbage collector — but fixes many of C's pain points:
- No undefined behavior — Zig makes UB explicit and detectable
- No hidden allocations — functions that allocate must accept an
Allocator - No preprocessor — metaprogramming via
comptimeinstead - Excellent C interop — can import C headers directly
- Cross-compilation built-in — target any platform from any platform
Your First Program
const std = @import("std"); pub fn main() void { std.debug.print("Hello, Zig!\n", .{}); }
@import is a built-in function (all builtins start with @). The standard library is imported as a value — no special syntax required.
Building & Running
# Install Zig from ziglang.org, then: zig init # create a new project zig build run # build and run zig run hello.zig # run a single file
Variables & Types
Zig is statically typed. Every variable has a type known at compile time. Immutability is the default.
const vs var
const x: i32 = 42; // immutable — cannot be reassigned var y: i32 = 10; // mutable y = 20; // OK // x = 99; // ERROR: cannot assign to constant
const whenever possible. Zig will warn you if you declare a var that's never mutated.
Primitive Types
| Type | Description | Example |
|---|---|---|
| i8, i16, i32, i64, i128 | Signed integers | -42 |
| u8, u16, u32, u64, u128 | Unsigned integers | 255 |
| f32, f64 | Floating point | 3.14 |
| bool | Boolean | true / false |
| usize | Pointer-sized unsigned int | array indices |
| comptime_int | Compile-time integer | literals |
Type Inference
const a = 100; // inferred as comptime_int const b: u8 = 100; // explicit u8 const pi = 3.14159; // inferred as comptime_float const name = "Alice"; // type: *const [5:0]u8 (string literal)
Integer Arithmetic
const a: u8 = 200; const b: u8 = 100; // Normal addition — panics on overflow in debug mode const sum = a + b; // Wrapping arithmetic — always wraps mod 256 const wrapped = a +% b; // 44 (wraps around) // Saturating arithmetic const sat = a +| b; // 255 (max u8)
+% for explicit wrapping semantics.
Functions
Functions in Zig are first-class. They must specify parameter types and return types explicitly. No overloading — use comptime generics instead.
Basic Function
fn add(a: i32, b: i32) i32 { return a + b; } pub fn main() void { const result = add(3, 4); std.debug.print("{d}\n", .{result}); // 7 }
pub makes a declaration visible outside its file. fn without pub is file-private.
Multiple Return Values
Zig doesn't have tuple returns, but you can return a struct:
const DivResult = struct { quotient: i32, remainder: i32, }; fn divmod(a: i32, b: i32) DivResult { return .{ .quotient = a / b, .remainder = a % b }; } const r = divmod(17, 5); // r.quotient == 3, r.remainder == 2
Generic Functions (comptime)
fn max(comptime T: type, a: T, b: T) T { return if (a > b) a else b; } const a = max(i32, 10, 20); // 20 const b = max(f64, 1.5, 0.9); // 1.5
comptime T: type to create type-generic functions resolved entirely at compile time. Zero runtime cost.
Control Flow
Zig's control flow is explicit and expression-oriented. if, while, and for can all produce values.
if / else
const x = 42; // Statement form if (x > 0) { std.debug.print("positive\n", .{}); } else if (x == 0) { std.debug.print("zero\n", .{}); } else { std.debug.print("negative\n", .{}); } // Expression form (ternary-like) const label = if (x > 0) "pos" else "non-pos";
while Loop
var i: u32 = 0; while (i < 5) : (i += 1) { std.debug.print("{d} ", .{i}); } // prints: 0 1 2 3 4 // while with a continue expression is Zig's "for loop"
for Loop (over ranges & slices)
const nums = [_]i32{ 10, 20, 30 }; // iterate values for (nums) |n| { std.debug.print("{d}\n", .{n}); } // iterate with index for (nums, 0..) |n, i| { std.debug.print("[{d}] = {d}\n", .{ i, n }); } // range (Zig 0.12+) for (0..5) |i| { std.debug.print("{d} ", .{i}); }
switch
const n: u8 = 3; const name = switch (n) { 1 => "one", 2, 3 => "two or three", // multiple values 4..10 => "four to ten", // range else => "other", }; // name == "two or three"
switch must cover all cases. If you miss a case, the compiler will error — no hidden fallthrough.
for loop over a range 0 to 4 (inclusive)?Arrays & Slices
Arrays have a fixed, compile-time length. Slices are a pointer + length pair — a view into any contiguous memory.
Arrays
// [N]T — N is the length, T is the element type const arr: [3]i32 = [3]i32{ 1, 2, 3 }; // Shorthand — let compiler count const arr2 = [_]i32{ 1, 2, 3 }; std.debug.print("{d}\n", .{arr2[1]}); // 2 std.debug.print("{d}\n", .{arr2.len}); // 3
Slices
const arr = [_]u8{ 10, 20, 30, 40, 50 }; // Slice: arr[start..end] (end is exclusive) const s: []const u8 = arr[1..4]; // s == { 20, 30, 40 }, s.len == 3 // Whole array as slice const all = arr[0..]; // all.len == 5
[N]T is an array of exactly N elements. []T is a slice — a fat pointer (ptr + len) that can point to any array or subrange. Functions should usually accept slices, not arrays, for flexibility.
Strings
Zig has no dedicated string type. Strings are []const u8 (a slice of bytes):
const name: []const u8 = "Alice"; std.debug.print("Hello, {s}!\n", .{name}); std.debug.print("Length: {d}\n", .{name.len}); // 5 // String comparison const eq = std.mem.eql(u8, name, "Alice"); // true
"hello" when assigned to a variable?Structs
Structs group related data together. In Zig, structs can also have methods — functions that take the struct as their first parameter.
Defining a Struct
const Point = struct { x: f32, y: f32, }; const p = Point{ .x = 3.0, .y = 4.0 }; std.debug.print("({d}, {d})\n", .{ p.x, p.y });
Struct Methods
const Vec2 = struct { x: f32, y: f32, pub fn length(self: Vec2) f32 { return std.math.sqrt(self.x * self.x + self.y * self.y); } pub fn scale(self: *Vec2, factor: f32) void { self.x *= factor; self.y *= factor; } }; var v = Vec2{ .x = 3.0, .y = 4.0 }; std.debug.print({d}\n", .{v.length()}); // 5.0 v.scale(2.0); // v.x=6, v.y=8
self: Vec2 — read-only, takes a copy.self: *Vec2 — mutable reference, can modify the struct.
Default Values
const Config = struct { width: u32 = 800, height: u32 = 600, fullscreen: bool = false, }; const cfg = Config{ .width = 1920 }; // height & fullscreen use defaults
Error Handling
Zig has no exceptions. Errors are values — returned explicitly and handled explicitly. This makes error handling visible and impossible to accidentally ignore.
Error Sets
const ParseError = error{ InvalidCharacter, Overflow, Empty, }; fn parseAge(s: []const u8) ParseError!u8 { if (s.len == 0) return error.Empty; return std.fmt.parseInt(u8, s, 10) catch error.InvalidCharacter; }
The ! in ParseError!u8 means "this function returns either a ParseError or a u8".
Handling Errors
// try — propagates errors up (like ? in Rust) fn run() !void { const age = try parseAge("25"); std.debug.print("Age: {d}\n", .{age}); } // catch — handle the error inline const age = parseAge("abc") catch |err| { std.debug.print("Error: {}\n", .{err}); return; }; // catch with default value const age2 = parseAge("bad") catch 0;
try expr is shorthand for expr catch |e| return e. Use try to propagate, catch to handle locally.
Anyerror
// !void means "anyerror!void" — can return any error pub fn main() !void { try someFunction(); }
try expr do in Zig?Pointers
Zig gives you direct control over memory via pointers. Unlike C, Zig's type system distinguishes single-item pointers from many-item pointers and prevents common bugs.
Single-Item Pointer
var x: i32 = 42; const ptr: *i32 = &x; // & takes the address ptr.* = 100; // .* dereferences std.debug.print("{d}\n", .{x}); // 100
Pointer Types
| Type | Meaning |
|---|---|
| *T | Single non-null pointer to T |
| ?*T | Optional (nullable) pointer to T |
| [*]T | Many-item pointer (unknown length) |
| []T | Slice: pointer + length |
| *const T | Immutable pointer to T |
*T can NEVER be null. Use ?*T to represent a nullable pointer. This eliminates a whole class of null-dereference bugs at compile time.
Heap Allocation
const std = @import("std"); pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); // check for leaks on exit const alloc = gpa.allocator(); const n = try alloc.create(i32); defer alloc.destroy(n); // guaranteed cleanup n.* = 99; std.debug.print("{d}\n", .{n.*}); }
defer runs a statement when the current scope exits — like Go's defer or C++'s RAII. It's the idiomatic way to pair allocation with deallocation.
ptr in Zig?Optionals
Zig uses ?T to represent a value that might be absent. This replaces null pointer hacks and makes "maybe nothing" explicit in the type system.
Optional Basics
var maybe: ?i32 = 42; maybe = null; // now absent // Unwrap with .? — panics if null const val = maybe.?; // Safe unwrap with orelse const safe = maybe orelse 0; // 0 if null // orelse with return/break const x = maybe orelse return;
if with optional capture
fn findUser(id: u32) ?[]const u8 { if (id == 1) return "Alice"; return null; } if (findUser(1)) |name| { std.debug.print("Found: {s}\n", .{name}); } else { std.debug.print("Not found\n", .{}); }
|name| syntax captures the unwrapped value inside the if block. This pattern is also used in while loops for iterators that return optionals.
while with optional
// Loops until iterator returns null while (iterator.next()) |item| { process(item); }
Comptime
Zig's most powerful feature: code that runs at compile time. It replaces C macros, C++ templates, and code generation tools — all with regular Zig syntax.
comptime values
// Evaluated at compile time const SIZE = 1024; const buf: [SIZE]u8 = undefined; // Explicit comptime expression const x = comptime (1 + 2 + 3); // == 6, computed at compile
Generic Data Structures
// A function that RETURNS a type fn Stack(comptime T: type) type { return struct { items: []T, top: usize = 0, pub fn push(self: *@This(), item: T) void { self.items[self.top] = item; self.top += 1; } }; } // Create a Stack of i32 const IntStack = Stack(i32);
type and return a type. This is how the standard library's ArrayList(T), HashMap(K, V), etc. work.
comptime if
fn doThing(comptime debug: bool) void { if (comptime debug) { // This entire branch compiled out in release std.debug.print("debug mode\n", .{}); } }
comptime T: type and returns type is used to create:@cImport, and the standard library at ziglang.org/documentation.