Skip to content
Make room for learningYour learning studio
Course outline

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.

zig
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.

zig
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?

zig
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:

zig
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.

zig
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.

zig
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:

zig
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:

zig
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:

zig
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:

zig
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?

  1. const window = readings[0..1];
  2. var readings = [_]Reading{ .{ .label = "inlet", .value = 40 }, .{ .label = "core", .value = 180 } };
  3. std.debug.print("before={} after={}\n", .{ first_before, readings[0].value });
  4. window[0].value = first_before + 59;
  5. 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.

zig
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:

  1. Create an array of your favorite numbers and calculate their average
  2. Define a Book struct with title, author, and pages. Create an array of books
  3. Write a struct method that modifies the struct (returns a new instance)
  4. Create a Circle struct with methods for area and circumference
  5. Build a Student struct that contains an array of test scores and a method to calculate GPA
  6. 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.

zig
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:

  1. Loosen the high-water mark. In isHigh, change self.value > 100 to self.value >= 100. Predict which check fails before running. The first two checks still pass, but the third fails: a reading of exactly 100 now counts as high, and the check exists precisely to pin down that above 100 excludes 100. One character in a comparison moves the boundary by one value.
  2. Ask for an array instead of a slice. Change highestValue's parameter to readings: [3]Reading and call it as highestValue(readings). Predict what happens before running. The first highestValue check can be made to pass, but the highestValue(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 self as 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.

Next lesson

Error Handling

Master Zig error handling — use error unions, error sets, try/catch, and errdefer to write robust code that fails gracefully without exceptions.

22 min