Tutorial: Learn Zig from Scratch (2026)
Zig is a systems programming language that aims to be practical, simple, and powerful. After working with C and Rust, Zig felt refreshing: no hidden control flow, no hidden allocations, and no hidden preprocessing. It gives you manual memory management with compile-time safety checks and a build system that is part of the language.
Zig's comptime is the killer feature — running code at compile time without a separate macro language, letting you build efficient abstractions without runtime overhead.
Comptime
comptime evaluates code at compile time. Any Zig expression can be marked comptime to run at compile time rather than runtime. This enables compile-time reflection, type generation, and code specialization. Comptime is not a separate meta-language - it is the same Zig syntax running in a compile-time interpreter.
fn Vector(comptime T: type, comptime n: usize) type {
return struct {
data: [n]T,
fn sum(self: @This()) T {
var s: T = 0;
for (self.data) |v| { s += v; }
return s;
}
};
}
const Vec3f = Vector(f32, 3);
const v = Vec3f{ .data = [_]f32{1.0, 2.0, 3.0} };
try expect(v.sum() == 6.0);
Allocators
Zig does not have a default allocator — you must pass one explicitly. This means you control when and how memory is allocated. The standard library provides page_allocator, arena_allocator, heap_allocator, and more. Passing allocators as parameters makes every allocation visible in the function signature.
const std = @import("std");
fn process(allocator: std.mem.Allocator) !void {
const list = try allocator.alloc(u8, 1024);
defer allocator.free(list);
const items = try allocator.dupe(u8, "hello");
defer allocator.free(items);
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
try process(gpa.allocator());
}
Error Handling
Errors are values, not exceptions. Functions that can fail return an error union type: !void or MyError!T. The try keyword unwraps or returns the error early. catch provides a fallback. Error sets are like enums that can be merged. The compiler tracks which errors a function can return.
const FileError = error{
NotFound,
PermissionDenied,
IoError,
};
fn readFile(path: []const u8) FileError![]u8 {
const file = std.fs.cwd().openFile(path, .{}) catch {
return FileError.NotFound;
};
defer file.close();
return file.readToEndAlloc(gpa.allocator(), 1024 * 1024);
}
pub fn main() !void {
const contents = try readFile("data.txt");
defer gpa.allocator().free(contents);
std.debug.print("{s}\n", .{contents});
}
Slices and Arrays
Arrays have a fixed length known at compile time. Slices ([]T) are a pointer and a length, representing a view into memory. Slices can be sliced further with slice[start..end]. Zig uses sentinel-terminated arrays for C interop. The length is not stored in the pointer but alongside it in the slice.
const array: [5]u8 = [_]u8{ 1, 2, 3, 4, 5 };
const slice: []const u8 = &array;
const sub = slice[1..3]; // view of [2, 3]
const sentinel: [:0]const u8 = "hello";
// compatible with C strings
// Multi-dimensional
const matrix: [3][4]f32 = [_][4]f32{
[_]f32{1, 2, 3, 4},
[_]f32{5, 6, 7, 8},
[_]f32{9, 10, 11, 12},
};
Cross Compilation
Zig ships with the necessary C libraries and headers for cross-compiling to any target platform. There is no need to install separate toolchains. Specify -target x86_64-windows or -target aarch64-linux-gnu. Zig's build system integrates with C source files, handling both Zig and C compilation transparently.
// Build with:
// zig build-exe main.zig -target aarch64-linux-gnu
// zig build-exe main.zig -target x86_64-windows-gnu
// zig build-exe main.zig -target riscv64-linux-gnu
pub fn main() void {
const os = @import("builtin").os.tag;
switch (os) {
.windows => std.debug.print("Windows\n", .{}),
.linux => std.debug.print("Linux\n", .{}),
.macos => std.debug.print("macOS\n", .{}),
else => std.debug.print("Other\n", .{}),
}
}
Build System
Zig's build system uses build.zig — a Zig file that defines the build configuration. There is no makefile, CMakeLists, or separate configuration format. The build function receives a Builder object to define executable targets, libraries, tests, and run steps. Dependencies are fetched via build.zig.zon.
// build.zig
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const mode = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "myapp",
.root_source_file = .{ .path = "src/main.zig" },
.target = target,
.optimize = mode,
});
b.installArtifact(exe);
const run = b.addRunArtifact(exe);
const run_step = b.step("run", "Run the app");
run_step.dependOn(&run.step);
}
Frequently Asked Questions
Zig vs Rust?
Zig is lower-level, has no hidden allocations, and easier C interop. Rust provides stronger safety guarantees through the borrow checker. Choose Zig for simplicity and C replacement; Rust for safety-critical systems.
What is undefined behavior in Zig?
Zig has much less UB than C/C++. Safety checks are runtime (stacks, bounds) and can be disabled per scope with `--release-fast` for performance.
How does Zig handle memory?
You pass allocators explicitly. No default allocator, no GC, no hidden allocations by the language. Every allocation is visible.
What is var vs const vs comptime?
var is mutable runtime, const is immutable runtime, comptime is evaluated at compile time. comptime can be used on variables, expressions, and function parameters.
Originally published on Ayodhyyya. Last updated June 1, 2026.