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:
1pub fn main() void {2 // do stuff3}Imports
Imports are done using the @import built-in function. The Zig standard library can be imported using the @import function as such:
1const 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:
1var a: u8 = 50;2const b: u8 = 50;3const c = -11;Arrays
Arrays have a fixed length which can either be declared or inferred. Declaring an array inline looks like this:
1var data: [3]u32 = [3]u32{ 1, 2, 3 };Inferring the type and the size can be done like so:
1var data = [_]u32{ 1, 2, 3 };Accessing a value from an array can be done using index notation:
1const x = data[1];2data[2] = x;Arrays can be concatenated using ++:
1const a = [_]u8{ 1, 2 };2const b = [_]u8{ 3, 4 };3
4const c = a ++ b;
++is acomptimeoperator and runs during compilation
Declaring an array without assigning any values can be done using undefined:
1var data: [3]u8 = undefined;Strings
Zig stores strings as an array of bytes. Strings are denoted using double quotes (")
1const str = "hello";2const h = str[0];The ++ operator can also be used to concatenate strings
1const result = "hello" ++ " " ++ "world";Multiline strings can also be defined. These use \\ at the start of each line with no surrounding quotes:
1const multiline =2 \\First line of string3 \\Second line of string4;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:
1const a = 10;2
3if (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:
1const a = 10;2const price = if (a > 5) "high" else "low";While Loops
The syntax for a while loop is also pretty normal looking:
1var a = 10;2while (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:
1var a = 5;2while (a < 10) : (a += 1) {3 std.debug.print("{}\n", .{a})4}Or:
1var a = 5;2while (a < 10) : (a += 1) {3 // this will not print but will run the continue expression4 if (a == 7) continue;5 std.debug.print("{}\n", .{a})6}Zig also has break statements that exit the loop immediately, for example:
1var a = 5;2while (a < 10) : (a += 1) {3 // this will exit the loop4 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:
1var a = 5;2while (a > 10) : (a += 1) {3 if (a == 7) break;4} else {5 // do something if (a > 10) == false6}While loops can also be used as expressions, this looks a bit like so:
1var a = 5;2const result = while (a > 10) : (a += 1) {3 if (a == 7) break a;4} else 05// required if using a while loop as an expression6// will be returned if while never breaksFor Loops
For loops iterate over arrays and have the following structure:
1const arr = [_]{ 1, 2, 3 };2
3for (arr) |item| {4 // do stuff with item5}Additionally, they also support the ability to use a provided indexer:
1const arr = [_]{ 1, 2, 3 };2
3for (arr, 0..) |item, i| {4 // do stuff with item and index5}For loops also have an else that will be executed if the for loop runs out of items, this looks like:
1const arr = [_]{ 1, 2, 3 };2
3const result = for (arr) |item| {4 if (item == 2) break;5} else {6 // do other stuff7};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:
1const arr = [_]{ 1, 2, 3 };2
3const 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:
1fn doThing() u8 {2 return 10;3}Functions can also take parameters:
1fn 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:
1const MyCustomError = error {2 MyErrorA,3 MyErrorB,4 MyErrorC,5};A function can return an error as a normal value like so:
1fn 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:
1const result = myError(x);2
3if (x == MyCustomError.MyErrorA) {4 // do stuff5}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 number2const myValue: MyCustomError!u8 = 5;We can define a function that returns an error union like:
1fn 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 error2const result: u8 = doThing(x) catch 0;catch also can capture the error and do something with it:
1const result: u8 = doThing(x) catch |err| {2 if (err == MyCustomError.MyErrorA) {3 return 10;4 }5
6 return 0;7}Zig also has try:
1const result = try doThing();Which is basically shorthand for the following:
1const result = doThing() catch |err| return err;Zig can also infer an error by leaving out the type of error and specifying the
!resultTypebut this isn’t recommended for any function other thanmainwhich can returnvoidor failfn main() !void
Defer
The defer keyword will run some code after a block exits:
1pub fn main() void {2 // this will get run last3 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:
1errdefer cleanup();2const result = try doThing();Switch
Switch statements must be exhaustive or have an else branch. This looks like so:
1pub 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:
1pub 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 22const 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:
1if (valOrErr) |val| {2 // do things with val3} else |err| {4 // do things with err5}This can also be combined with a switch to handle specific types of errors:
1if (valOrErr) |val| {2 // do things with val3} else |err| switch (err) {4 MyCustomError.MyErrorA => {5 // handle error6 },7 MyCustomError.MyErrorB => {8 // handle error9 },10}Enums
Enums are values that can be one of a specific list. Defining an enum looks like so:
1const MyEnum = enum {2 valA,3 valB,4 valC,5};Enums are just numbers and so we can assign actual values to them as well:
1const 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:
1const MyStruct = struct {2 enu: MyEnum,3 val: u8,4};Creating an instance of a struct can be done using the following syntax:
1const thing = MyStruct {2 .enu = MyEnum.ValA,3 .val = 10,4};Structs properties can also accessed and modified:
1var thing = MyStruct {2 .enu = MyEnum.ValA,3 .val = 10,4};5
6thing.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:
1var a: u8 = 1;2const a_ptr: *u8 = &a;3
4const b: u8 = a_ptr.*;You can make
constpointers tovarvalues but you cannot makevarpointers toconstvalues
Zig also lets you define constant pointers which can point at constants or variables:
1var a: u8 = 1;2const a_ptr: *const u8 = &a;3
4const b: u8 = 1;5const b_prt: *const u8 = &b;Pointers let us pass values by reference, for example updating the passed variable:
1fn 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:
1var a = [2]u8{ 1, 2 };2var 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:
1var a = [2]u8{ 1, 2 };2var a_ptr: [*]u8 = &a;3var a_ptr_to_slice []const u8 = a_ptr[0..a.len];Optionals
Optional values may have data or null and are typed using the ?:
1var data: ?u8 = 10;Optional values can be defaulted using orelse:
1var definitely_data: u8 = data orelse 0;This can also be used to do things like early return or break a loop:
1var definitely_data: u8 = data orelse return false;2// continue using definitely_dataIt’s also possible to define optional values on structs using the ?:
1const MyStruct = struct {2 required: u8,3 optional: ?u8,4};Another shorthand is using .? to mean orelse unreachable:
1const x: ?u8 = 5;2const 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
1const 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:
undefined- not yet a valuenull- explicitly non-valueerrors- no value because there was an errorvoid- 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:
1const data = [4]u8{1, 2, 3, 4};2const slice1: []u8 = data[0..1];3const slice2: []u8 = data[1..3];4const 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:
1const MyUnion = union {2 a: u8,3 b: bool,4}When defining a union you can only specify one of the types:
1const a = MyUnion { .a = 1 };2const 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:
1const MyTag = enum { a, b }:2const 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:
1switch (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:
1const 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:
1const a: u8 = 10;2const b: u16 = a; // coerces u8 to u16Types can only be coerced to compatible type. It’s also possible to coerce types directly to optional or error types:
1const MyError = error{ConversionError};2const a: u8 = 10;3const maybe_a: ?u8 = a;4const err_a: MyError!u8 = a;Block Labels
Blocks can also have labels, this looks like so:
1my_label: {2 // block with stuff in it3}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:
1const 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