Zig Basic
Zig is a low-level systems programming language that aims to be a modern replacement for C. It emphasizes explicitness — no hidden control flow, no hidden memory allocations, and no macros that obscure what the code is actually doing. If you enjoy understanding how things work at a fundamental level, Zig rewards that mindset.
Hello World
There are two common ways to print output in Zig. The simplest is std.debug.print, which writes directly to stderr. For production code or when you need stdout, you use the IO stream approach.
Using debug print
const std = @import("std");
const print = std.debug.print;
pub fn main() void {
print("Hello World in Zig \n", .{});
}
std.debug.print takes a format string and a tuple of arguments — that’s what the .{} is. An empty tuple means no format arguments. The @import is a builtin that loads the standard library. Aliasing std.debug.print to print at the top is a common convention in Zig code.
Using IO Stream
const std = @import("std");
pub fn main(init: std.process.Init) !void {
try std.Io.File.stdout().writeStreamingAll(init.io, "Hello world\n");
}
This approach writes to stdout explicitly via the IO abstraction introduced in newer versions of Zig. The !void return type means the function can return an error or void — the ! is Zig’s way of saying “this might fail”. The try keyword propagates any error up to the caller automatically, saving you from manually checking error returns every time.
Running Tests
Zig has a built-in test runner. You write tests in the same file as your code using the test block, then run them with zig test.
const std = @import("std");
const expect = std.testing.expect;
// run with: zig test filename.zig
test "always success" {
try expect(true);
}
// test "always failed" {
// try expect(false);
// }
std.testing.expect asserts that a condition is true — if it isn’t, it returns an error, which try then propagates, causing the test to fail. This is a deliberate design: Zig doesn’t have a special assertion mechanism; it just uses the same error-handling system you use everywhere else.
Variables
In Zig, every variable is either const (immutable) or var (mutable). There is no implicit mutability — you have to be explicit about which one you want. The compiler will also warn you if a var is never actually mutated, nudging you toward const by default.
const std = @import("std");
pub fn main() void {
// const — value cannot change after assignment, type is inferred
const y = 123;
// var — mutable, type must be explicitly annotated
var x: i32 = 12;
// undefined — allocates the variable but leaves its value as garbage
// use only when you're about to write to it before reading
var z: i32 = undefined;
std.debug.print("z is {}\n", .{z});
std.debug.print("{}+{} = {}\n", .{ y, x, y + x });
z = 12;
x = 2;
std.debug.print("{}+{} = {}\n", .{ y, x, y + x });
std.debug.print("z is {}\n", .{z});
}
undefined is a Zig-specific value that tells the compiler the memory is intentionally uninitialized. In debug builds, Zig fills it with a recognizable bit pattern (0xaa) so it’s easier to spot accidental reads. In release builds, no initialization happens at all. Either way, reading from an undefined variable before writing to it is a bug — use it only when you know you’ll assign a real value before the first read.
Type annotations on var are required because Zig won’t infer types for mutable variables from assignment alone — it needs the type up front so the compiler can enforce it across all future assignments. const can infer the type from the value since it never changes.
Identifier
Zig identifiers follow standard rules: start with a letter or underscore, followed by letters, digits, or underscores. No starting with digits, no special characters.
const std = @import("std");
// valid identifiers
const myVar = 10;
const _myvar = 20;
const my_var = 5;
const _my_1_var = 20;
// invalid — cannot start with a digit
// const 1valuemore = 20;
// @"..." lets you use anything as an identifier, including spaces and reserved words
// useful when interoperating with external systems that use names Zig wouldn't normally allow
const @"my weird variable name with spaces" = 32;
const @"!this isweirdly not recommended" = 20;
const @"1valuemore" = 2;
pub fn main() void {
std.debug.print("my weird variable name with spaces is: {}\n", .{@"my weird variable name with spaces"});
}
The @"..." syntax is an escape hatch for identifiers that would otherwise be illegal — things like names with spaces, starting with numbers, or using reserved keywords as names. It’s occasionally useful when binding to external C libraries where the function names don’t follow Zig’s conventions, but you should avoid it in your own code. If you find yourself reaching for it in your own code, it’s usually a sign the name should be rethought.
Shadowing
Shadowing is when you declare a new variable with the same name as one that already exists in an outer scope. Many languages (like Rust) allow this deliberately. Zig does not — it treats it as an error, which forces you to use distinct names and makes code easier to read without needing to track which “version” of a variable you’re looking at.
const std = @import("std");
pub fn main() void {
const x = 20;
// inner scope
{
// const x = 10; // error — x is already declared in an enclosing scope
std.debug.print("Inner scope: {}\n", .{x}); // uses the outer x
}
std.debug.print("Outer scope:{}\n", .{x});
// the same restriction applies inside if blocks and loops
if (true) {
// const x = 10; // error
}
var y: i32 = 10;
while (y < 12) {
// const x = 20; // error — still can't shadow x from outer scope
y += 1;
hello(y);
}
}
fn hello(y: i32) void {
// var y: i32 = 10; // error — cannot shadow the parameter name
const x = 10; // fine — this is a completely separate function scope
std.debug.print("y is:{}\nx is:{}\n", .{ y, x });
}
The key distinction is that each function has its own scope — so declaring x inside hello doesn’t conflict with x in main. Shadowing is only disallowed within the same lexical chain of nested scopes. Function parameters count as part of the function’s scope, so you can’t redeclare a parameter name inside the function body either.
Defer
defer schedules a statement to run at the end of the current scope, regardless of how that scope exits — whether it returns normally, returns early, or hits an error. This makes it perfect for cleanup tasks like freeing memory or closing file handles, because you can write the cleanup right next to the acquisition.
Example usage of Defer
test "defer" {
var x: i16 = 5;
{
defer x += 2; // runs when this inner scope exits
try expect(x == 5); // x is still 5 here
}
// after the scope exits, defer runs, x becomes 7
try expect(x == 7);
}
test "multi defer" {
// multiple defers execute in reverse order (LIFO — last in, first out)
var x: f32 = 5;
{
try expect(x == 5);
defer x += 2; // runs second: 2.5 + 2 = 4.5
defer x /= 2; // runs first: 5 / 2 = 2.5
}
try expect(x == 4.5);
}
The LIFO ordering is intentional — it mirrors how you’d naturally want cleanup to happen in reverse order of acquisition. If you open a file then allocate memory, you want to free the memory before closing the file.
Errors
Zig has no exceptions. Instead, errors are values — declared similarly to enums — that you explicitly handle or propagate. Every possible error in a Zig program is tracked at compile time.
const std = @import("std");
const expect = std.testing.expect;
const ExampleError = error{
Example1Error,
Example2Error,
Example3Error,
};
Each error name is globally unique and assigned a unique integer ID by the compiler at compile time. This means an error named Example2Error has the same identity wherever it appears — in ExampleError or in any other error set.
const AnotherExampleError = error{
Example2Error, // same name as ExampleError.Example2Error
};
Because the global identity is determined by name, AnotherExampleError.Example2Error and ExampleError.Example2Error are the same error value — they share the same ID. This is what makes the subset/superset relationship work.
Coercing error sets
An error set with fewer members is a subset of one with more members. Zig allows implicit coercion from a subset to a superset, but not the other way around — you can’t assign a superset to a variable typed as the subset, because the subset might not be able to represent all possible error values.
test "coerce error from a subset to a superset" {
// AnotherExampleError is a subset of ExampleError, so this coercion is valid
const err: ExampleError = AnotherExampleError.Example2Error;
try expect(err == ExampleError.Example2Error); // true — same global ID
}
The following code fails to compile — you cannot assign a superset to a subset type, because ExampleError contains errors that AnotherExampleError has no way to represent:
test "coerce error from superset to subset — will not compile" {
const err: AnotherExampleError = ExampleError.Example2Error;
try expect(err == AnotherExampleError.Example2Error);
}
Conditions
Zig’s if works largely as you’d expect, but there are a couple of differences worth noting compared to languages like JavaScript or C.
const std = @import("std");
const print = std.debug.print;
pub fn main() void {
const number = 10;
if (number > 10) {
print("Number is greater than 10.\n", .{});
} else if (number > 5) {
print("Number is between 6 to 10.\n", .{});
} else {
print("Number is 5 or less. \n", .{});
}
// Zig has no ternary operator (a > 5 ? c : d)
// Instead, if-else is an expression — it can return a value directly
const a: u32 = 5;
const b: u32 = 4;
const result = if (a != b) 47 else 32;
print("Result is: {}\n", .{result});
// switch is also covered here for simple day-of-week style matching
const day: u32 = 3;
switch (day) {
1 => print("It's Monday\n", .{}),
2 => print("It's Tuesday\n", .{}),
3 => print("It's Wednesday\n", .{}),
4 => print("It's Thursday\n", .{}),
5 => print("It's Friday\n", .{}),
else => print("It's some other day\n", .{}),
}
}
One important rule: the condition in an if must be a bool. Zig doesn’t do implicit truthiness — you can’t write if (someInteger) and have it treated as true when non-zero. You have to be explicit: if (someInteger != 0). This eliminates a whole category of subtle bugs that come from C-style truthy checks.
The inline if expression (if (a != b) 47 else 32) replaces the ternary operator. It’s cleaner because it uses the same keyword as the regular if statement — there’s nothing new to learn, and it reads naturally from left to right.
Looping
Zig has for loops for iterating over slices and arrays, and while loops for condition-based iteration. There is no traditional C-style for (init; cond; step) — the while loop with a continue expression covers that case.
const std = @import("std");
const print = std.debug.print;
pub fn main() void {
const items = [_]i32{ 4, 5, 3, 4, 0 };
var sum: i32 = 0;
// for loop — captures each element by value
for (items) |value| {
if (value == 0) {
continue;
}
sum += value;
}
print_item(items[0..]);
print("Sum: {}\n", .{sum});
// basic while loop
var i: u8 = 2;
while (i < 10) {
print("i:{},", .{i});
i *= 2;
}
print("\n", .{});
// while loop with a continue expression — runs after each iteration, like the step in a C for loop
var j: u8 = 2;
while (j < 10) : (j *= 2) {
print("j:{},", .{j});
}
print("\n", .{});
}
fn print_item(items: []const i32) void {
print("[", .{});
// capture both value and index by passing a range 0.. alongside the slice
for (items, 0..) |value, index| {
print(" {} ", .{value});
if (index != items.len - 1) {
print(",", .{});
}
}
print("]\n", .{});
}
A few things worth noting: [_]i32 tells the compiler to infer the array length from the literal. The 0.. syntax in a multi-argument for generates an integer range starting at 0. Slices like items[0..] give you a view into the array — they carry both a pointer and a length.
Switch
Zig’s switch must be exhaustive — every possible value needs to be handled, either explicitly or via else. This is enforced at compile time, which catches bugs from missing cases.
const std = @import("std");
const print = std.debug.print;
pub fn main() void {
const myValue = 5;
// basic switch — one value per arm
switch (myValue) {
1 => print("I choose one\n", .{}),
2 => print("I choose two\n", .{}),
3 => print("I choose three\n", .{}),
4 => print("I choose four\n", .{}),
5 => print("I choose five\n", .{}),
else => print("I choose not in range\n", .{}),
}
// range matching with ... (inclusive on both ends)
switch (myValue) {
0...3 => print("I choose between 0 to 3\n", .{}),
4...7 => print("I choose between 4 to 7\n", .{}),
else => print("I choose beyond 7\n", .{}),
}
// multiple values per arm using a comma
switch (myValue) {
1, 2 => print("I choose 1 or 2\n", .{}),
3, 4 => print("I choose 3 or 4\n", .{}),
5, 6 => print("I choose 5 or 6\n", .{}),
7, 8 => print("I choose 7 or 8\n", .{}),
else => print("I choose beyond 8 \n", .{}),
}
// switch as an expression — each arm returns a value
var result: u32 = 0;
result = switch (myValue) {
4 => 5 * 2,
5 => 5 * 10,
6 => 5 * 100,
else => 5 * 1,
}; // semicolon required when used as a statement
print("Result: {}\n", .{result});
}
The switch expression form is particularly useful — instead of assigning inside each arm, the entire switch evaluates to a value. This keeps variable assignment clean and avoids needing a mutable variable initialized to a default before the switch.
Pointers
Zig’s pointer system is more explicit than C’s, but that explicitness is the point. The compiler distinguishes between different kinds of pointers — single-item, multi-item, slices — and enforces what you can do with each.
const std = @import("std");
const print = std.debug.print;
const expect = std.testing.expect;
pub fn main() void {
const b: u8 = 5;
var x: u8 = 10;
print("before[x]={}\n", .{x});
increment(&x); // pass a pointer to x using &
print("after[x]={}\n", .{x});
// y is a const pointer, but since x is var, we can still mutate through it
const y: *u8 = &x;
y.* += 1; // dereference with .*
print("y={}\n", .{y.*});
// y = &b; // this would fail — y is const, so the pointer itself can't change
// z points to a const value — we can't mutate through it
var z = &b;
// z.* += 1; // compile error — b is const
print("z={}\n", .{z.*});
// but z itself is var, so we can point it somewhere else
z = &x;
print("z={}\n", .{z.*});
}
fn increment(x: *u8) void {
x.* += 1; // dereference to access the value
}
The const in a pointer declaration controls whether the pointer itself can be reassigned, not whether the pointed-to value can be mutated. What controls mutation is whether the pointee is const or var. This is consistent with how const works everywhere else in Zig.
test "optional pointer (null)" {
var x: u8 = 10;
// ?*u8 is an optional pointer — it can be null
// regular *u8 can never be null, which eliminates a whole class of bugs
var optPtr: ?*u8 = &x;
// safely unwrap with an if capture
if (optPtr) |notNullPtr| {
defer notNullPtr.* += 1; // increment x when this scope exits
try expect(notNullPtr.* == 10);
}
try expect(x == 11);
optPtr = null; // detach from x
}
test "multi item pointer" {
// C pointers (*T) can point into arrays and support arithmetic
// Zig separates this into [*]T — a multi-item pointer with arithmetic but no known length
// The separation makes intent explicit and helps the compiler catch mistakes
var myarr = [_]u8{ 1, 3, 5, 2, 9 };
const ptr: [*]u8 = &myarr;
print("First: {}, Last: {}\n", .{ ptr[0], ptr[myarr.len - 1] });
try expect(ptr[0] == 1);
// pointer arithmetic is still possible, but you do it explicitly
const next_ptr = ptr + 1;
try expect(next_ptr[0] == 3);
print("next_ptr[0]: {}\n", .{next_ptr[0]});
}
test "slice vs pointer to array" {
var myarr = [_]u8{ 1, 2, 3, 4, 5 };
// *[5]u8 — pointer to an array of known compile-time length
const myptr: *[5]u8 = &myarr;
print("MyPtr.len = {}, myptr[0] = {}\n", .{ myptr.len, myptr[0] }); // len is compile-time known
// []u8 — a slice, which is essentially a struct containing a [*]T pointer and a runtime length
// often called a "fat pointer" for this reason
const myslice: []u8 = myarr[1..4]; // elements at index 1, 2, 3
print("myslice.len = {}, myslice[0] = {}\n", .{ myslice.len, myslice[0] });
}
The slice vs pointer-to-array distinction matters in practice: if you need to pass an array around and the length is always known at compile time, a pointer to the array is fine. If the length varies at runtime — like when you’re working with a subsection of a buffer — slices are the right tool. Slices are ubiquitous in Zig precisely because they carry their own length, making them safe to pass across function boundaries without also passing a separate length parameter.