TL;DR
Learn Zig arrays and structs — create fixed-size arrays, define structs with methods, and organize data structures for clean, efficient programs.
Key concepts
- zig arrays
- zig structs
- zig data structures
- zig struct methods
- zig arrays tutorial
Arrays and Structs
In this lesson, we'll explore Zig's data structures. You'll learn how to work with arrays, slices, and structs to organize and manage your data effectively.
What You'll Learn
By the end of this lesson you will have modelled a sensor reading as a struct with named fields, written a read-only method and a method that returns a modified copy rather than mutating in place, and scanned a whole collection of those records through a single slice-taking function that works on the full array and on any window of it.
That is this lesson's record-parsing milestone, and it is the foundation the record-analyzer is built on — this is the one lesson in the opening arc the capstone genuinely cannot do without. The analyzer reads a file of pipe-delimited rows, turns each row into a struct with named fields, and then answers questions by walking that collection. Everything it reports depends on the data model you build here.
You arrive here able to branch, loop, and switch inside a function body (Control Flow). What is new is that the data those loops walk now has a shape: a named type with named fields, stored in a collection you can hand around as a whole.
Behaviour First: An Array Knows Its Own Size
Arrays in Zig have a fixed size known at compile time. Click Run and read the four lines before reading on — pay attention to the last two, where a length is reported for an array whose size was never written down.
const std = @import("std");
pub fn main() !void {
// Array with explicit size and type
const numbers: [5]i32 = [5]i32{1, 2, 3, 4, 5};
// Array with inferred size (use [_])
const fruits = [_][]const u8{"apple", "banana", "cherry"};
// Access elements
std.debug.print("First number: {}\n", .{numbers[0]});
std.debug.print("Second fruit: {s}\n", .{fruits[1]});
// Array length
std.debug.print("Array length: {}\n", .{numbers.len});
// Initialize array with same value
const zeros = [_]i32{0} ** 10; // Array of 10 zeros
std.debug.print("Zeros length: {}\n", .{zeros.len});
}
Array Operations
This one is fully worked, with a note on why each shape is what it is. The examples that follow get progressively less help.
const std = @import("std");
pub fn main() !void {
// `var`, not `const` — an array is a value, so making the binding mutable
// is what makes its elements assignable.
var numbers = [_]i32{10, 20, 30, 40, 50};
numbers[0] = 15;
std.debug.print("Modified first element: {}\n", .{numbers[0]});
// The plain form hands you a COPY of each element. Fine for reading; it is
// also why assigning to `num` here would change nothing.
for (numbers) |num| {
std.debug.print("{} ", .{num});
}
std.debug.print("\n", .{});
// The second operand is an open range supplying an index alongside each
// element — you do not maintain a counter, so you cannot desynchronise one.
for (numbers, 0..) |num, i| {
std.debug.print("[{}]: {} ", .{i, num});
}
std.debug.print("\n", .{});
// The accumulator is declared OUTSIDE the loop so that it survives between
// iterations. Inside, it would reset on every pass.
var sum: i32 = 0;
for (numbers) |num| {
sum += num;
}
std.debug.print("Sum: {}\n", .{sum});
}
Before moving on to slices, it is worth settling what an array is when you assign one to another name. Trace this by hand first.
Predict
Two new names are made from the same array: one by assignment, one by slicing the whole range. Then each is written through. What do the two lines print?
const std = @import("std");
pub fn main() !void {
var original = [_]i32{ 1, 2, 3 };
var copy = original; // assignment
const view = original[0..]; // whole-range slice
copy[0] = 99;
view[1] = 88;
std.debug.print("original: {} {} {}\n", .{ original[0], original[1], original[2] });
std.debug.print("copy: {} {} {}\n", .{ copy[0], copy[1], copy[2] });
}Assignment copies, slicing aliases: original prints 1 88 3 and copy prints 99 2 3. An array is a value type whose size is part of its type, so var copy = original duplicates all three elements; a slice is a pointer plus a length pointing at storage that already exists, so writing through view reaches original itself.
Slices
Slices are views into arrays with runtime-known length:
const std = @import("std");
pub fn main() !void {
const numbers = [_]i32{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// Create a slice (pointer + length)
const slice1 = numbers[0..5]; // Elements 0-4
const slice2 = numbers[5..]; // Elements 5 to end
const slice3 = numbers[0..]; // Entire array as slice
std.debug.print("First slice: ", .{});
for (slice1) |num| {
std.debug.print("{} ", .{num});
}
std.debug.print("\n", .{});
std.debug.print("Second slice: ", .{});
for (slice2) |num| {
std.debug.print("{} ", .{num});
}
std.debug.print("\n", .{});
// Full-range slice covers every element
std.debug.print("Full slice: ", .{});
for (slice3) |num| {
std.debug.print("{} ", .{num});
}
std.debug.print("\n", .{});
// Slice length
std.debug.print("Slice1 length: {}, full slice length: {}\n", .{ slice1.len, slice3.len });
// String is a slice of bytes
const message: []const u8 = "Hello, Zig!";
std.debug.print("Message: {s}\n", .{message});
std.debug.print("Message length: {}\n", .{message.len});
// Try creating your own slices!
}
Structs
Structs group related data together. Fewer notes here — one is left for you.
const std = @import("std");
const Person = struct {
name: []const u8,
age: u32,
height: f32,
// `self: Person` is a value parameter, which by the rule from Functions
// means it is a const COPY — so this method can read every field and
// change none of them. The signature is the read-only guarantee.
pub fn introduce(self: Person) void {
std.debug.print("Hi, I'm {s}, {} years old, {d:.2}m tall\n",
.{self.name, self.age, self.height});
}
pub fn create(name: []const u8, age: u32, height: f32) Person {
return Person{
.name = name,
.age = age,
.height = height,
};
}
};
pub fn main() !void {
// Create struct instance
const person1 = Person{
.name = "Alice",
.age = 30,
.height = 1.75,
};
// Access fields
std.debug.print("Name: {s}\n", .{person1.name});
std.debug.print("Age: {}\n", .{person1.age});
// Call method
person1.introduce();
// Use associated function
const person2 = Person.create("Bob", 25, 1.80);
person2.introduce();
}
Your note to write: create has no self parameter, so it is called as Person.create("Bob", 25, 1.80) rather than on an instance — while introduce is called as person1.introduce(). Say
what job that leaves create, given that it has no instance to read from. The clue is what it
returns.
Notice that introduce takes self: Person — the struct itself, by value. That is fine for a method that only reads. The next program tries to write, and the compiler stops it.
Debug
This should add 5 and then 7 to a counter and print 'Total: 12'. The compiler rejects it and points inside the add method. Commit a hypothesis about what add is actually allowed to change, then fix it so it prints 12.
const std = @import("std");
const Counter = struct {
total: i32,
pub fn add(self: Counter, amount: i32) void {
self.total += amount;
}
};
pub fn main() !void {
var counter = Counter{ .total = 0 };
counter.add(5);
counter.add(7);
std.debug.print("Total: {}\n", .{counter.total});
}Expected output:
Total: 12
A method's self is just its first parameter, so it is a const holding a copy: self.total += amount is error: cannot assign to constant, and even if it compiled it would be modifying a duplicate that vanishes when add returns. Declaring self: *Counter and keeping the body as self.total += amount fixes it, and the call sites are unchanged — Zig takes the address for you when the method wants a pointer. Use self: T for methods that only read and self: *T for methods that modify.
Struct Patterns
Common patterns for working with structs:
const std = @import("std");
const Rectangle = struct {
width: f32,
height: f32,
// Calculate area
pub fn area(self: Rectangle) f32 {
return self.width * self.height;
}
// Calculate perimeter
pub fn perimeter(self: Rectangle) f32 {
return 2 * (self.width + self.height);
}
// Check if square
pub fn isSquare(self: Rectangle) bool {
return self.width == self.height;
}
// Resize
pub fn scale(self: Rectangle, factor: f32) Rectangle {
return Rectangle{
.width = self.width * factor,
.height = self.height * factor,
};
}
};
pub fn main() !void {
const rect = Rectangle{
.width = 10.0,
.height = 5.0,
};
std.debug.print("Area: {d:.2}\n", .{rect.area()});
std.debug.print("Perimeter: {d:.2}\n", .{rect.perimeter()});
std.debug.print("Is square: {}\n", .{rect.isSquare()});
const scaled = rect.scale(2.0);
std.debug.print("Scaled width: {d:.2}\n", .{scaled.width});
}
Nested Structs
Structs can contain other structs:
const std = @import("std");
const Address = struct {
street: []const u8,
city: []const u8,
zip: []const u8,
};
const Employee = struct {
name: []const u8,
id: u32,
address: Address,
pub fn displayInfo(self: Employee) void {
std.debug.print("Employee: {s} (ID: {})\n", .{self.name, self.id});
std.debug.print("Address: {s}, {s} {s}\n",
.{self.address.street, self.address.city, self.address.zip});
}
};
pub fn main() !void {
const employee = Employee{
.name = "Alice Smith",
.id = 12345,
.address = Address{
.street = "123 Main St",
.city = "Springfield",
.zip = "12345",
},
};
employee.displayInfo();
}
Nested structs are the first place the const/var rule from Variables and Data Types starts to have reach. Close the code and think it through.
Recall
Without scrolling up: in Variables and Data Types you learned that const makes a binding immutable, and in Control Flow you learned that for (items) |item| binds one element per pass. Put them together — given const employee = Employee{ ... } holding a nested Address, and a for loop over an array of Employees, what can you write to?
Neither is writable. const in Zig is deep — freezing employee freezes its nested Address too, and fields carry no mutability of their own, so the binding you reached them through decides everything. And a plain for capture is an ordinary binding holding a copy of the element; to modify elements in place you need a var array and a |*item| capture that gives you a pointer to the real one.
Arrays of Structs
Combine arrays and structs for collections of data:
const std = @import("std");
const Point = struct {
x: f32,
y: f32,
pub fn distance(self: Point, other: Point) f32 {
const dx = self.x - other.x;
const dy = self.y - other.y;
return @sqrt(dx * dx + dy * dy);
}
pub fn display(self: Point) void {
std.debug.print("({d:.1}, {d:.1})", .{self.x, self.y});
}
};
pub fn main() !void {
// Array of points
const points = [_]Point{
Point{ .x = 0.0, .y = 0.0 },
Point{ .x = 3.0, .y = 4.0 },
Point{ .x = 1.0, .y = 1.0 },
};
// Display all points
for (points, 0..) |point, i| {
std.debug.print("Point {}: ", .{i});
point.display();
std.debug.print("\n", .{});
}
// Calculate distances
const origin = points[0];
std.debug.print("\nDistances from origin:\n", .{});
for (points[1..], 1..) |point, i| {
const dist = origin.distance(point);
std.debug.print("Point {}: {d:.2}\n", .{i, dist});
}
// Try adding more points or methods!
}
Now assemble a sequence that proves the array-versus-slice distinction rather than just stating it. Assume a struct is already declared above these lines:
const Reading = struct {
label: []const u8,
value: i32,
};
Arrange the code
These five statements sit inside main, below the Reading struct shown above. They build a two-record array, take a view of its first record, snapshot that record's value, raise it through the view, and report the value before and after. They are shuffled. Put them in the order that prints before=40 after=99 — then answer what the order is really testing: which line has to read the old value while it still exists, and what does it report otherwise?
const window = readings[0..1];var readings = [_]Reading{ .{ .label = "inlet", .value = 40 }, .{ .label = "core", .value = 180 } };std.debug.print("before={} after={}\n", .{ first_before, readings[0].value });window[0].value = first_before + 59;const first_before = window[0].value;
Packed Structs
Third stage of the fade: no rationale notes — only labels, and a reason you have to extract yourself. An ordinary Zig struct makes no promise about how its fields are laid out in memory — the compiler may reorder them or insert padding to keep each field on an address the hardware likes. That is almost always what you want, and it is why nothing so far in this lesson has mentioned layout.
A packed struct gives that freedom up. Its fields are laid out in declaration order with no
padding, so the whole thing occupies exactly the number of bits its fields add up to and can be
reinterpreted as an integer of that width. You pay for this in convenience and speed of access; you
buy the ability to describe a layout that something outside your program already decided — a
hardware register, a network packet header, a file format's flag byte. Read the program, then check
the size it reports against the plain-struct figures underneath it.
const std = @import("std");
// Packed struct - guaranteed memory layout
const Flags = packed struct {
is_active: bool,
is_admin: bool,
is_verified: bool,
_padding: u5 = 0, // Pad to 8 bits
};
pub fn main() !void {
const flags = Flags{
.is_active = true,
.is_admin = false,
.is_verified = true,
};
std.debug.print("Active: {}\n", .{flags.is_active});
std.debug.print("Admin: {}\n", .{flags.is_admin});
std.debug.print("Verified: {}\n", .{flags.is_verified});
std.debug.print("Size: {} bytes\n", .{@sizeOf(Flags)});
}
That prints 1 byte. The same three fields in an ordinary struct Flags { is_active: bool, is_admin: bool, is_verified: bool } occupy 3 bytes, because each bool is given its own
addressable byte — check it yourself with @sizeOf, and check the bit counts with @bitSizeOf,
which reports 8 for the packed version and 24 for the plain one. The _padding: u5 = 0 field is
what rounds three flag bits up to a whole byte; without it the type would be 3 bits wide and could
not be handed to anything expecting a byte.
The payoff is that a packed struct can be reinterpreted directly as the integer it occupies:
const as_byte: u8 = @bitCast(flags); gives 00000101 in binary for the values above — the three
flags in declaration order, then the padding. That is what makes packed structs the tool for
speaking a layout you did not choose. For the data modelling in this track, and in the capstone,
use an ordinary struct — you want the compiler free to lay your records out for speed, and nothing
outside your program is reading their bytes.
The array-versus-slice split is not a Zig quirk — it is the same split Rust draws, with the same consequences and one important difference. Recognising which half carries over is the point.
Transfer
Zig distinguishes [5]i32, an array whose length is part of its type, from []i32, a slice that is a pointer plus a runtime length. Rust draws the same line between [i32; 5] and &[i32]. Which statement names what the two languages actually share here, rather than a surface resemblance?
Practice Exercises
Try these exercises:
- Create an array of your favorite numbers and calculate their average
- Define a
Bookstruct with title, author, and pages. Create an array of books - Write a struct method that modifies the struct (returns a new instance)
- Create a
Circlestruct with methods for area and circumference - Build a
Studentstruct that contains an array of test scores and a method to calculate GPA - Create a slice from an array and modify elements through the slice
Challenge: Create a TodoList struct that contains an array of Task structs (each with description and completed status). Add methods to mark tasks complete and count remaining tasks.
Try It Yourself
Reading about structs is not the same as modelling data with them. This is a build task: a small program that reports its own pass/fail. Two struct methods and one free function are stubbed out — a read-only predicate, a method that returns a modified copy, and a scan over a slice of structs. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each until every check passes and it prints All checks passed.
There is no assert to import: the starter defines its own check helper that prints the failure and returns an error. Because an error returned from main ends the program with a non-zero exit status, the first failing check stops everything — no hidden control flow, exactly as Zig prefers it.
Build
Finish the build. Two methods and one function are stubbed out and the checks below them fail until each returns the right value. Run it as-is to see which check fails first, decide what shape that function has to hand back, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
const std = @import("std");
// A tiny stand-in for an assert: print what went wrong and return an error.
// An error returned from main ends the program with a non-zero exit status.
fn check(ok: bool, comptime message: []const u8) !void {
if (!ok) {
std.debug.print("CHECK FAILED: {s}\n", .{message});
return error.CheckFailed;
}
}
const Reading = struct {
label: []const u8,
value: i32,
// TODO 1: report whether this reading is above 100. Read-only, so self
// should stay a plain value parameter.
// Reading{ .label = "core", .value = 180 }.isHigh() -> true
pub fn isHigh(self: Reading) bool {
_ = self;
return false;
}
// TODO 2: return a NEW Reading with the same label and the value
// multiplied by factor. Do not try to modify self.
// Reading{ .label = "inlet", .value = 40 }.scaled(3) -> value 120, label "inlet"
pub fn scaled(self: Reading, factor: i32) Reading {
_ = factor;
return self;
}
};
// TODO 3: return the largest value in the slice. The slice always has at
// least one element, so you can seed the answer from the first one.
// highestValue(&.{ 40, 180, 95 }) -> 180
fn highestValue(readings: []const Reading) i32 {
_ = readings;
return 0;
}
// --- Build checks: Do not edit below this line. ---
pub fn main() !void {
const readings = [_]Reading{
.{ .label = "inlet", .value = 40 },
.{ .label = "core", .value = 180 },
.{ .label = "outlet", .value = 95 },
};
try check(readings[1].isHigh(), "the core reading of 180 should count as high");
try check(!readings[0].isHigh(), "the inlet reading of 40 should not count as high");
const edge = Reading{ .label = "edge", .value = 100 };
try check(!edge.isHigh(), "exactly 100 is not ABOVE 100");
try check(readings[0].scaled(3).value == 120, "scaled(3) on a value of 40 should give 120");
try check(std.mem.eql(u8, readings[0].scaled(3).label, "inlet"), "scaled should carry the label across unchanged");
try check(readings[0].value == 40, "scaled must not modify the original reading");
try check(highestValue(&readings) == 180, "highestValue should find 180");
const window = readings[1..];
try check(highestValue(window) == 180, "highestValue should work on a slice of the array too");
try check(window.len == 2, "readings[1..] should be a 2-element view, not a copy");
std.debug.print("All checks passed.\n", .{});
std.debug.print("Highest: {}\n", .{highestValue(&readings)});
std.debug.print("Scaled inlet: {}\n", .{readings[0].scaled(3).value});
std.debug.print("Window length: {}\n", .{window.len});
}Expected output:
All checks passed. Highest: 180 Scaled inlet: 120 Window length: 2
Once it passes, try two variations and predict each before running:
- Loosen the high-water mark. In
isHigh, changeself.value > 100toself.value >= 100. Predict which check fails before running. The first two checks still pass, but the third fails: a reading of exactly100now counts as high, and the check exists precisely to pin down thatabove 100excludes100. One character in a comparison moves the boundary by one value. - Ask for an array instead of a slice. Change
highestValue's parameter toreadings: [3]Readingand call it ashighestValue(readings). Predict what happens before running. The firsthighestValuecheck can be made to pass, but thehighestValue(window)call no longer compiles at all —readings[1..]is a 2-element slice and cannot become a[3]Reading. Taking a slice is what makes the function work on any window of the data.
Capstone milestone
Milestone — record parsing. The capstone's Record Analyzer reads rows of raw data into an array of structs, then walks that array to answer questions about it. The Reading struct you just built is that model in miniature: named fields, read-only methods, and a scan over a slice. Confirm you can model a record and query a collection of them.
Hint: You don't need the full analyzer yet — this confirms the data-modelling fluency the parser is built on. In the capstone, one struct per row and a slice-taking scan function are exactly how records get counted, filtered, and summarised.
- Defined a struct with named fields to model one row of data
- Wrote a read-only method taking self by value, and confirmed it cannot modify the struct
- Wrote a method that returns a NEW struct instead of mutating self
- Stored records in an array and scanned them through a slice parameter, so the same function works on a sub-range
Key Takeaways
- Arrays have compile-time known size; slices have runtime-known size
- Use
[_]for array size inference and[start..end]for slicing - Structs group related data and can contain methods
- Methods take
selfas first parameter; associated functions don't - Structs can be nested and stored in arrays
- Packed structs provide guaranteed memory layout for low-level programming
Next Steps
Now that you can organize data with arrays and structs, a natural question arises: what happens when something goes wrong? Functions fail, files go missing, and input arrives in unexpected shapes. Zig handles all of this through its error union system, which bakes failure information directly into return types, so ignoring an error is something you have to write down rather than something that happens by default.
Ready to try it? Continue in your learning studio to practice these ideas, save your progress, and experiment with code.