Introduction to Zig

Updated: 17 June 2026

The Main Function

Zig functions are private by default. Making a function public can be done using pub

A main function must be public and looks like this:

1
pub fn main() void {
2
// do stuff
3
}

Imports

Imports are done using the @import built-in function. The Zig standard library can be imported using the @import function as such:

1
const std = @import("std");

Variables

Zig uses const to define constants and var to variables. These declarations can also have types associated with them though these can also be inferred for constants:

1
var a: u8 = 50;
2
const b: u8 = 50;
3
const c = -11;

Arrays

Arrays have a fixed length which can either be declared or inferred. Declaring an array inline looks like this:

1
var data: [3]u32 = [3]u32{ 1, 2, 3 };

Inferring the type and the size can be done like so:

1
var data = [_]u32{ 1, 2, 3 };

Accessing a value from an array can be done using index notation:

1
const x = data[1];
2
data[2] = x;

Arrays can be concatenated using ++:

1
const a = [_]u8{ 1, 2 };
2
const b = [_]u8{ 3, 4 };
3
4
const c = a ++ b;

++ is a comptime operator and runs during compilation

Declaring an array without assigning any values can be done using undefined:

1
var data: [3]u8 = undefined;

Strings

Zig stores strings as an array of bytes. Strings are denoted using double quotes (")

1
const str = "hello";
2
const h = str[0];

The ++ operator can also be used to concatenate strings

1
const result = "hello" ++ " " ++ "world";

Multiline strings can also be defined. These use \\ at the start of each line with no surrounding quotes:

1
const multiline =
2
\\First line of string
3
\\Second line of string
4
;

String literals in Zig are immutable values and are represented using []const u8 (an immutable slice of u8 values, see Slices

If/Else

If/else statements in Zig work as you’d expect:

1
const a = 10;
2
3
if (a > 5) {
4
std.debug.print("is big\n", .{});
5
} else {
6
std.debug.print("is small\n", .{});
7
}

Zig also has If/else expressions which can be used like so:

1
const a = 10;
2
const price = if (a > 5) "high" else "low";

While Loops

The syntax for a while loop is also pretty normal looking:

1
var a = 10;
2
while (a > 5) {
3
a -= 1;
4
}

Loops also have an optional “continue expression” which is run at the end of the loop or whenever a continue is invoked:

1
var a = 5;
2
while (a < 10) : (a += 1) {
3
std.debug.print("{}\n", .{a})
4
}

Or:

1
var a = 5;
2
while (a < 10) : (a += 1) {
3
// this will not print but will run the continue expression
4
if (a == 7) continue;
5
std.debug.print("{}\n", .{a})
6
}

Zig also has break statements that exit the loop immediately, for example:

1
var a = 5;
2
while (a < 10) : (a += 1) {
3
// this will exit the loop
4
if (a == 7) break;
5
std.debug.print("{}\n", .{a})
6
}

While loops also have an else statement that can be used and will be executed when the while condition becomes false:

1
var a = 5;
2
while (a > 10) : (a += 1) {
3
if (a == 7) break;
4
} else {
5
// do something if (a > 10) == false
6
}

While loops can also be used as expressions, this looks a bit like so:

1
var a = 5;
2
const result = while (a > 10) : (a += 1) {
3
if (a == 7) break a;
4
} else 0
5
// required if using a while loop as an expression
6
// will be returned if while never breaks

For Loops

For loops iterate over arrays and have the following structure:

1
const arr = [_]{ 1, 2, 3 };
2
3
for (arr) |item| {
4
// do stuff with item
5
}

Additionally, they also support the ability to use a provided indexer:

1
const arr = [_]{ 1, 2, 3 };
2
3
for (arr, 0..) |item, i| {
4
// do stuff with item and index
5
}

For loops also have an else that will be executed if the for loop runs out of items, this looks like:

1
const arr = [_]{ 1, 2, 3 };
2
3
const result = for (arr) |item| {
4
if (item == 2) break;
5
} else {
6
// do other stuff
7
};

Loops can also be used as expressions and will result in the value that the loop break is called. A loop must also have an else to provide a fallback if no value is returned from the loop if it is used as an expression:

1
const arr = [_]{ 1, 2, 3 };
2
3
const result = for (arr) |item| {
4
if (item == 2) break item;
5
} else 0;

Functions

Functions are defined using the fn keyword along with a return type:

1
fn doThing() u8 {
2
return 10;
3
}

Functions can also take parameters:

1
fn times2(n: u8) u16 {
2
return n * 2;
3
}

Errors

Errors are values in Zig. Defining a set of errors can be done like so:

1
const MyCustomError = error {
2
MyErrorA,
3
MyErrorB,
4
MyErrorC,
5
};

A function can return an error as a normal value like so:

1
fn myError(n: u8) MyCustomError {
2
if (n == 0) return MyCustomError.MyErrorA;
3
if (n == 1) return MyCustomError.MyErrorB;
4
5
return MyCustomError.MyErrorC;
6
}

And then be handled by comparing the values:

1
const result = myError(x);
2
3
if (x == MyCustomError.MyErrorA) {
4
// do stuff
5
}

Additionally, values can also be a type of an “error union” in which they can represent a value or an error:

1
// this can hold an error or a number
2
const myValue: MyCustomError!u8 = 5;

We can define a function that returns an error union like:

1
fn doThing(n: u8) MyCustomError!u8 {
2
if (n == 0) return MyCustomError.MyErrorA;
3
4
return n;
5
}

And we can use it with a catch to provide a fallback value:

1
// result will be 0 if doThing returns an error
2
const result: u8 = doThing(x) catch 0;

catch also can capture the error and do something with it:

1
const result: u8 = doThing(x) catch |err| {
2
if (err == MyCustomError.MyErrorA) {
3
return 10;
4
}
5
6
return 0;
7
}

Zig also has try:

1
const result = try doThing();

Which is basically shorthand for the following:

1
const result = doThing() catch |err| return err;

Zig can also infer an error by leaving out the type of error and specifying the !resultType but this isn’t recommended for any function other than main which can return void or fail fn main() !void

Defer

The defer keyword will run some code after a block exits:

1
pub fn main() void {
2
// this will get run last
3
defer std.debug.print("End of function\n", .{});
4
5
std.debug.print("Start of function\n', .{});
6
}

Note that if there are multiple defers

There is also errdefer which runs if a block exits with an error:

1
errdefer cleanup();
2
const result = try doThing();

Switch

Switch statements must be exhaustive or have an else branch. This looks like so:

1
pub fn doThing(num: u8) u8 {
2
switch (num) {
3
1 => return 'a',
4
2 => return 'b',
5
3 => return 'c',
6
4 => return 'd',
7
else => return 'x',
8
}
9
}

Switches can also be used as an expression, like so:

1
pub fn doThing(num: u8) u8 {
2
const result = switch (num) {
3
1 => 'a',
4
2 => 'b',
5
3 => 'c',
6
4 => 'd',
7
else => 'x',
8
};
9
10
return result;
11
}

Unreachable

Zig also has an unreachable statement that lets us tell the compiler that a branch should never be executed and that hitting it would be an error:

1
// num is only ever 1 or 2
2
const result = switch (num) {
3
1 => 'a',
4
2 => 'b',
5
else => unreachable,
6
};

If/Else/Error

When working with errors, it’s also possible to use an error-handling version of an if statement that looks like so:

1
if (valOrErr) |val| {
2
// do things with val
3
} else |err| {
4
// do things with err
5
}

This can also be combined with a switch to handle specific types of errors:

1
if (valOrErr) |val| {
2
// do things with val
3
} else |err| switch (err) {
4
MyCustomError.MyErrorA => {
5
// handle error
6
},
7
MyCustomError.MyErrorB => {
8
// handle error
9
},
10
}

Enums

Enums are values that can be one of a specific list. Defining an enum looks like so:

1
const MyEnum = enum {
2
valA,
3
valB,
4
valC,
5
};

Enums are just numbers and so we can assign actual values to them as well:

1
const MyEnum = enum {
2
valA = 1,
3
valB = 3,
4
valC = 5,
5
};

Enums can be converted to integers using @intFromEnum(x)

Structs

The syntax to define a struct looks like so:

1
const MyStruct = struct {
2
enu: MyEnum,
3
val: u8,
4
};

Creating an instance of a struct can be done using the following syntax:

1
const thing = MyStruct {
2
.enu = MyEnum.ValA,
3
.val = 10,
4
};

Structs properties can also accessed and modified:

1
var thing = MyStruct {
2
.enu = MyEnum.ValA,
3
.val = 10,
4
};
5
6
thing.val = 20;

Pointers

You can create a pointer to a value can be created with & and pointers can be dereferenced with name.* The type of a pointer for type is *type:

1
var a: u8 = 1;
2
const a_ptr: *u8 = &a;
3
4
const b: u8 = a_ptr.*;

You can make const pointers to var values but you cannot make var pointers to const values

Zig also lets you define constant pointers which can point at constants or variables:

1
var a: u8 = 1;
2
const a_ptr: *const u8 = &a;
3
4
const b: u8 = 1;
5
const b_prt: *const u8 = &b;

Pointers let us pass values by reference, for example updating the passed variable:

1
fn update(x: *u8, val: u8) void {
2
x.* = val;
3
}

Note that if we’ve got a pointer to a struct we can still access the members directly without having to dereference first: x.inner instead of x.*.inner

It’s also possible to create a pointer to the items of an array directly and not the base array. This requires us to keep track of the points but can look something like this:

1
var a = [2]u8{ 1, 2 };
2
var a_ptr: [*]u8 = &a;

Zig calls this a many-item pointer since it’s a pointer to many items but you need to keep track of how many items

Converting a many-pointer back to a value can be done by accessing the range that represents the source data, for example:

1
var a = [2]u8{ 1, 2 };
2
var a_ptr: [*]u8 = &a;
3
var a_ptr_to_slice []const u8 = a_ptr[0..a.len];

Optionals

Optional values may have data or null and are typed using the ?:

1
var data: ?u8 = 10;

Optional values can be defaulted using orelse:

1
var definitely_data: u8 = data orelse 0;

This can also be used to do things like early return or break a loop:

1
var definitely_data: u8 = data orelse return false;
2
// continue using definitely_data

It’s also possible to define optional values on structs using the ?:

1
const MyStruct = struct {
2
required: u8,
3
optional: ?u8,
4
};

Another shorthand is using .? to mean orelse unreachable:

1
const x: ?u8 = 5;
2
const y: u8 = x orelse unreachable;

Methods

Structs can have methods on them. If the first argument is an instance of the struct of a pointer to one then it can be namespaced by the instance instead of being namespaced by the struct

1
const MyStruct = struct {
2
required: u8,
3
optional: ?u8,
4
5
// this can be any name you want, self is fine:
6
pub fn bigRequired(self: *MyStruct) u8 {
7
return self.required * 2;
8
}
9
};

Non-Values

Zig has different indicators for non-values:

  1. undefined - not yet a value
  2. null - explicitly non-value
  3. errors - no value because there was an error
  4. void - type stating there will never be a value

Slices

Since arrays are fixed size using them can become a bit awkward. A lot of the time we can work with an array of any given size. In Zig these arrays are called slices. Slices provide a pointer to the start and a length of the respective array

Defining a slice based on an array uses a range to take from our array:

1
const data = [4]u8{1, 2, 3, 4};
2
const slice1: []u8 = data[0..1];
3
const slice2: []u8 = data[1..3];
4
const slice3: []u8 = data[2..];

Slices are denoted with the structure []T. When slicing from an array, the range is defined as being [x..y] which takes the items from index x to y-1. Leaving out the second value will take items until the end

Unions

Unions can store multiple types at the same memory location. This is because the compiler reserves the memory of the largest item to store

Unions can be defined as so:

1
const MyUnion = union {
2
a: u8,
3
b: bool,
4
}

When defining a union you can only specify one of the types:

1
const a = MyUnion { .a = 1 };
2
const b = MyUnion { .b = false };

Zig also has tagged unions that allow us to easily determine which union we’re using, tags can be defined explicitly:

1
const MyTag = enum { a, b }:
2
const MyUnion = union(MyTag) {
3
a: u8,
4
b: bool,
5
};

Using a tagged union makes it possible for us to easily switch on the type of the value:

1
switch (value) {
2
.a => |a| std.debug.print("A: {}", .{a}),
3
.b => |b| std.debug.print("B: {}", .{b}),
4
}

In many cases though, the tag will just be the name of the keys, and this can be inferred using the enum tag:

1
const MyUnion = union(enum) {
2
a: u8,
3
b: bool,
4
};

Floats

Zig supports IEEE-754 floating point numbers, namely: f16, f32, f64, f80, f128. Numbers that overflow their size will become inf or -inf. The relevant float type should also be selected based on their size and rounding properties

Decimal and exponential formatting can be done using the formatting strings like {d}, {d:.3} for decimal formatting with or without relevant decimal places, and similarly for exponential notation with {e} or {e:.3} for example

Coercion

Type coercion can be done fairly simply by specifying the other type that you’d like to coerce data into, for example:

1
const a: u8 = 10;
2
const b: u16 = a; // coerces u8 to u16

Types can only be coerced to compatible type. It’s also possible to coerce types directly to optional or error types:

1
const MyError = error{ConversionError};
2
const a: u8 = 10;
3
const maybe_a: ?u8 = a;
4
const err_a: MyError!u8 = a;

Block Labels

Blocks can also have labels, this looks like so:

1
my_label: {
2
// block with stuff in it
3
}

Providing a label allows us to use break to exit from that block. Since break lets us return a value from a block. Additionally, you can also break using a label to break something that’s not a direct block:

1
const a = outer_loop: while (true) {
2
while (true) {
3
break outer_loop: 1;
4
}
5
} else 0;

We can also use the continue keyword to continue a labeled loop just like with break

References