Coding · Programming languages
Zig tutorial for beginners
Short answer
Zig is a general-purpose systems programming language and toolchain meant as a modern alternative to C. It has no hidden control flow, no garbage collector and no hidden memory allocations: you pass an allocator explicitly, handle errors with error unions and try, and clean up with defer. Compile-time code execution (comptime) replaces macros and generics. Run a file with zig run hello.zig. Zig is still before version 1.0, so details of the language and standard library change between releases.
What Zig is and who uses it
Zig was created by Andrew Kelley and is developed by the non-profit Zig Software Foundation. It targets the same work as C: operating system components, embedded firmware, game engines, databases and fast command-line tools. The Zig compiler can also compile C and C++ code (zig cc) and cross-compile to many targets, so some projects use it only as a C toolchain.
Well-known projects written in Zig include the Bun JavaScript runtime, the TigerBeetle financial database and the Ghostty terminal emulator.
Install Zig and run Hello, world
Download a prebuilt archive for your system from ziglang.org/download, unpack it and add the folder to your PATH, or install Zig with your system package manager. There is no separate installer or runtime.
// hello.zig
const std = @import("std");
pub fn main() void {
std.debug.print("Hello, world!\n", .{});
}zig run hello.zig # compile and run
# Hello, world!
zig build-exe hello.zig # produce an executable named hello
zig init # create a project with build.zigstd.debug.print writes to standard error and is the simplest way to print in every recent version. The second argument is a tuple of values for the format placeholders; .{} is an empty tuple. The API for writing to standard output has changed between releases, so check your version's standard library docs for it.
Variables and basic types
const declares a value that never changes and var declares a mutable variable. The compiler is strict: an unused local variable is an error, and a var that is never modified is also an error, so use const by default.
| Type | Example | Notes |
|---|---|---|
| Signed integers | i8, i32, i64 | Any width is allowed, e.g. i7 |
| Unsigned integers | u8, u32, usize | usize is pointer-sized, used for lengths and indexes |
| Floats | f32, f64 | IEEE 754 |
| bool | true, false | No implicit conversion from integers |
| Array | [3]u8 | Fixed length known at compile time |
| Slice | []const u8 | Pointer plus length; strings are byte slices |
| Optional | ?i32 | A value or null |
| Error union | !u32 | A value or an error |
const std = @import("std");
pub fn main() void {
const age: u32 = 30;
var count: i32 = 0;
count += 1;
const name = "Ana"; // string literal, coerces to []const u8
std.debug.print("{s} is {d}, count = {d}\n", .{ name, age, count });
var maybe: ?i32 = null;
maybe = 5;
const value = maybe orelse 0;
std.debug.print("value = {d}\n", .{value});
}Format placeholders say how to print: {d} for numbers, {s} for strings, {c} for a single byte as a character, {any} for debugging any value.
Control flow
if, switch, while and for can all be used as expressions. switch must cover every possible value, so you usually add an else branch.
const std = @import("std");
pub fn main() void {
const n: i32 = -2;
const sign: []const u8 = if (n > 0) "positive" else if (n < 0) "negative" else "zero";
const score: u8 = 85;
const letter: u8 = switch (score) {
90...100 => 'A',
80...89 => 'B',
else => 'C',
};
std.debug.print("{s} {c}\n", .{ sign, letter }); // negative B
var i: u32 = 0;
while (i < 3) : (i += 1) {
std.debug.print("{d} ", .{i}); // 0 1 2
}
const items = [_]u8{ 10, 20, 30 };
for (items, 0..) |item, index| {
std.debug.print("\n{d}: {d}", .{ index, item });
}
std.debug.print("\n", .{});
}[_]u8{ ... } lets the compiler count the array length. for (items, 0..) loops over the items and a counter at the same time; the values between pipes are called captures.
Functions and comptime
Functions declare the types of parameters and the return type. Arguments are immutable inside the function. Generic code is written with comptime parameters: a type is a value that exists at compile time, so a function can take a type as an argument.
const std = @import("std");
fn add(a: i32, b: i32) i32 {
return a + b;
}
fn max(comptime T: type, a: T, b: T) T {
return if (a > b) a else b;
}
pub fn main() void {
std.debug.print("{d}\n", .{add(2, 3)}); // 5
std.debug.print("{d}\n", .{max(i32, 3, 7)}); // 7
std.debug.print("{d}\n", .{max(f64, 1.5, 0.5)}); // 1.5
}The compiler creates a separate version of max for each type it is called with. The same mechanism is used to build generic data structures in the standard library: a function that takes a type and returns a new struct type.
Error handling with error unions, try and defer
Errors are values. A return type !u32 means "a u32 or an error". try expr returns the error to the caller if there is one; catch handles it on the spot. Errors cannot be ignored silently.
const std = @import("std");
fn parseAge(s: []const u8) !u32 {
const n = try std.fmt.parseInt(i32, s, 10);
if (n < 0) return error.Negative;
return @intCast(n);
}
pub fn main() void {
const age = parseAge("-5") catch |err| {
std.debug.print("error: {s}\n", .{@errorName(err)});
return;
};
std.debug.print("age {d}\n", .{age});
}
// error: Negativedefer runs a statement when the current block exits, and errdefer runs it only when the block exits with an error. They keep cleanup code next to the code that acquired the resource.
Memory and allocators
Zig has no garbage collector and no global hidden allocator. Any function that needs heap memory takes an std.mem.Allocator parameter, so the caller decides where memory comes from (the page allocator, an arena, a fixed buffer, or a debugging allocator that detects leaks).
const std = @import("std");
pub fn main() !void {
const allocator = std.heap.page_allocator;
const buf = try allocator.alloc(u32, 5);
defer allocator.free(buf);
for (buf, 0..) |*slot, i| {
slot.* = @intCast(i * i);
}
std.debug.print("{any}\n", .{buf}); // { 0, 1, 4, 9, 16 }
}|*slot| captures a pointer to each element so the loop can write to it, and slot.* dereferences the pointer. In Debug and ReleaseSafe builds, integer overflow and out-of-bounds indexing stop the program with a clear panic instead of causing undefined behavior.
A small complete program: average of numbers
const std = @import("std");
fn average(numbers: []const i64) ?f64 {
if (numbers.len == 0) return null;
var sum: i64 = 0;
for (numbers) |n| sum += n;
const total: f64 = @floatFromInt(sum);
const len: f64 = @floatFromInt(numbers.len);
return total / len;
}
pub fn main() !void {
const allocator = std.heap.page_allocator;
const buf = try allocator.alloc(i64, 16);
defer allocator.free(buf);
var it = std.mem.tokenizeScalar(u8, "4 8 15 16 23 42", ' ');
var count: usize = 0;
while (it.next()) |word| : (count += 1) {
buf[count] = try std.fmt.parseInt(i64, word, 10);
}
if (average(buf[0..count])) |avg| {
std.debug.print("count={d} avg={d:.2}\n", .{ count, avg });
}
}
// count=6 avg=18.00The program splits a string on spaces, parses each piece with try, stores the numbers in heap memory freed by defer, and returns an optional average that is null for an empty list.
Where to go next
- The language reference at ziglang.org/documentation, choosing the page for your installed version.
- The Learn section of ziglang.org, including "Getting Started" and an overview of the language.
- Ziglings, a set of small broken programs you fix one by one, is a popular community exercise set.
- Read the source of the standard library: it is written in Zig and is the best reference for current APIs.
FAQ
Is Zig ready for production?
Some companies use Zig in production, but the language is pre-1.0, so you should expect breaking changes when you upgrade the compiler and plan time for them.
Is Zig a replacement for C or for Rust?
Zig is closest to C: manual memory management with better safety checks, error handling and compile-time code. Rust guarantees memory safety through its ownership system and borrow checker, which Zig does not have.
Can Zig use C libraries?
Yes. Zig can import C header files and call C functions directly, and the Zig compiler can build C and C++ source files, which makes mixing the languages straightforward.
What does comptime mean in Zig?
comptime marks code or parameters that are evaluated while compiling. Zig uses it for generics, constant tables and compile-time checks instead of a separate macro language.