Slices and Iteration
Slices are one of Zig's most important types. They provide a safe, fat-pointer view into arrays with both a pointer and a length, enabling flexible data access without copying.
What You'll Learn
By the end of this lesson you will take a block of data you do not own, narrow it down to just the part you care about, and walk it — with an index when you need one, in parallel with a second sequence when the problem calls for that — without copying a byte. The build task asks you to write functions that answer questions about a run of numbers handed to them as a slice, whatever size the caller's data happens to be.
This is the record-parsing milestone the analyzer is built on. Every line the analyzer reads arrives as a run of bytes, and every field it pulls out of that line is a view into those same bytes rather than a fresh copy — which is why the analyzer can look at a file without duplicating it. Arrays And Structs already gave you the fixed-size array and showed you a slice of one; what is new here is treating the slice itself as the thing you pass around, so that a function stops caring how big the original was and starts caring only about the window it was handed.
Slices in Depth
A slice []T is a pointer and a length. It can reference any contiguous block of memory.
const std = @import("std");
pub fn main() !void {
const numbers = [_]i32{ 10, 20, 30, 40, 50, 60, 70, 80 };
// Slice the full array
const all = numbers[0..];
std.debug.print("All: ", .{});
for (all) |n| std.debug.print("{} ", .{n});
std.debug.print("(len={})\n", .{all.len});
// Partial slices
const first_three = numbers[0..3];
const middle = numbers[2..6];
const last_two = numbers[6..];
std.debug.print("First 3: ", .{});
for (first_three) |n| std.debug.print("{} ", .{n});
std.debug.print("\n", .{});
std.debug.print("Middle: ", .{});
for (middle) |n| std.debug.print("{} ", .{n});
std.debug.print("\n", .{});
std.debug.print("Last 2: ", .{});
for (last_two) |n| std.debug.print("{} ", .{n});
std.debug.print("\n", .{});
// Mutable slices
var mutable = [_]i32{ 1, 2, 3, 4, 5 };
const slice: []i32 = &mutable;
slice[0] = 100;
std.debug.print("Modified: {}\n", .{mutable[0]});
}
The range in numbers[2..6] has a start and an end, and only one of them is included. Work out both printed lines below before you run it.
Predict
Trace this by hand. What length does the slice have, and which element does its last position hold?
const numbers = [_]i32{ 10, 20, 30, 40, 50 };
const part = numbers[1..4];
std.debug.print("len = {}\n", .{part.len});
std.debug.print("last = {}\n", .{part[part.len - 1]});The slice has length 3 and its last element is 40. Zig's ranges are half-open: numbers[1..4] includes indices 1, 2, and 3 but stops just before 4, so end - start is exactly the length. And a slice re-indexes from zero, so part[0] is the original numbers[1]. Those two facts together are why adjacent slices like numbers[0..3] and numbers[3..] tile an array with no gap and no overlap.
Sentinel-Terminated Slices
Some slices end with a special sentinel value, like null-terminated C strings.
const std = @import("std");
pub fn main() !void {
// String literals are null-terminated slices
const hello: [:0]const u8 = "Hello";
std.debug.print("String: {s} (len={})\n", .{ hello, hello.len });
// The sentinel is accessible at index len
std.debug.print("Sentinel at [{}]: {}\n", .{ hello.len, hello[hello.len] });
// Convert between slice types
const plain: []const u8 = hello; // [:0] -> [] is safe
std.debug.print("As plain slice: {s} (len={})\n", .{ plain, plain.len });
// Iterate over bytes
std.debug.print("Bytes: ", .{});
for (hello) |byte| {
std.debug.print("{} ", .{byte});
}
std.debug.print("\n", .{});
}
For Loop Patterns
Zig's for loops work with slices, arrays, and ranges.
const std = @import("std");
pub fn main() !void {
const names = [_][]const u8{ "Alice", "Bob", "Charlie", "Diana" };
// Basic iteration
std.debug.print("Names: ", .{});
for (names) |name| {
std.debug.print("{s} ", .{name});
}
std.debug.print("\n", .{});
// With index
for (names, 0..) |name, i| {
std.debug.print(" {}: {s}\n", .{ i, name });
}
// Multiple slices in parallel
const scores = [_]i32{ 95, 87, 92, 88 };
std.debug.print("\nScores:\n", .{});
for (names, scores) |name, score| {
std.debug.print(" {s}: {}\n", .{ name, score });
}
// Mutable iteration
var values = [_]i32{ 1, 2, 3, 4, 5 };
for (&values) |*v| {
v.* *= 2;
}
std.debug.print("\nDoubled: ", .{});
for (values) |v| std.debug.print("{} ", .{v});
std.debug.print("\n", .{});
}
The mutable-iteration form above has two pieces of punctuation doing real work: the & on the operand and the * on the capture. The next program tries to mutate without either. Commit a hypothesis before you fix it.
Debug
This program should triple every score in place and print the tripled list. It does not build at all. Say why before you change anything, then fix it so the tripled scores print.
const std = @import("std");
pub fn main() void {
var scores = [_]i32{ 4, 9, 12 };
for (scores) |score| {
score = score * 3;
}
std.debug.print("Tripled: ", .{});
for (scores) |score| std.debug.print("{} ", .{score});
std.debug.print("\n", .{});
}Expected output:
Tripled: 12 27 36
The capture is the problem. A plain for (scores) |score| binds each element by value, and that binding is a constant, so the assignment fails to build with error: cannot assign to constant — and even if it compiled you would only be writing to a copy. The compiler prints a second error above it, local variable is never mutated, suggesting const for scores; that is a red herring produced by the real bug, because the only write to the array is the one it just rejected. In-place mutation needs both halves of the idiom: for (&scores) |*score| { score.* = score.* * 3; }. The & makes the loop iterate a pointer to the array so each capture can be a pointer, the * in |*score| makes it one, and score.* writes through it to the original element.
Now build one. The lines below are the body of a function that pulls the value out of a
name:value field, ignoring any spaces in front of it — shuffled, and one plausible arrangement
runs perfectly well while printing the wrong text.
Arrange the code
These lines form a function that skips leading spaces, finds the colon, and prints everything after it. Nothing is copied — each step just narrows the view onto the same bytes. Reorder them so the printed value is correct, then answer the question the order is really testing: the colon position is a number measured against one view and then used to cut another, so what happens to that number when the two views are not the same?
view = view[colon + 1 ..];}while (view.len > 0 and view[0] == ' ') view = view[1..];var view = raw;fn fieldValue(raw: []const u8) void {std.debug.print("[{s}] len={}\n", .{ view, view.len });const colon = std.mem.indexOfScalar(u8, view, ':') orelse return;
std.mem Utilities
The standard library provides functions for working with memory and slices.
const std = @import("std");
pub fn main() !void {
const text = "Hello, World! Hello, Zig!";
// Find substring
if (std.mem.indexOf(u8, text, "World")) |pos| {
std.debug.print("'World' found at position {}\n", .{pos});
}
// Count occurrences
const count = std.mem.count(u8, text, "Hello");
std.debug.print("'Hello' appears {} times\n", .{count});
// Split
std.debug.print("\nSplit by space:\n", .{});
var iter = std.mem.splitScalar(u8, text, ' ');
while (iter.next()) |word| {
std.debug.print(" '{s}'\n", .{word});
}
// Equality
const a = "hello";
const b = "hello";
const c = "world";
std.debug.print("\n'hello' == 'hello': {}\n", .{std.mem.eql(u8, a, b)});
std.debug.print("'hello' == 'world': {}\n", .{std.mem.eql(u8, a, c)});
// Starts/ends with
std.debug.print("starts with 'Hello': {}\n", .{std.mem.startsWith(u8, text, "Hello")});
std.debug.print("ends with 'Zig!': {}\n", .{std.mem.endsWith(u8, text, "Zig!")});
}
Look at how indexOf was called above — with an if and a |pos| capture, not a plain assignment. Close the code and work out why that shape was necessary.
Recall
Without scrolling up: std.mem.indexOf is wrapped in an if with a |pos| capture, while std.mem.startsWith is assigned straight to a variable. Which return types force that difference, and what would go wrong if you indexed with indexOf's result directly?
std.mem.indexOf returns ?usize — an optional — because a substring might simply not be there, and if (…) |pos| hands you the usize only on the branch where a match exists. startsWith, endsWith, eql, and count always have an answer, so they return a plain bool or usize and need no unwrapping. Passing the un-unwrapped optional to a subscript is a compile error, not a runtime surprise: the compiler will not let you skip the null check.
The slice is the one Zig type that has an almost exact counterpart in Rust, and lining the two up is a good way to check what you actually believe about it.
Transfer
Zig's []const i32 and Rust's &[i32] are both a pointer plus a length pointing into memory somebody else owns, and slicing either one is a couple of instructions rather than a copy. Which statement names what the two genuinely share, rather than a resemblance that breaks down?
Try It Yourself
Reading about slices is not the same as reaching for one by intent. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out — one returns an optional because it can fail on empty input, one mutates a slice in place, one uses a std.mem search. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each one until every check passes and it prints All checks passed.
There is no assert to import: the starter defines its own check helper, which 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.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each one behaves correctly. Run it as-is to see which check fails first, decide what that function needs, 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");
// TODO 1: return the largest value in the slice, or null if the slice is empty.
// Note the return type: an empty slice has no maximum, so say so with null
// rather than inventing a number.
// findMax(&.{ 4, 19, 7 }) -> 19, findMax(&.{}) -> null
fn findMax(values: []const i32) ?i32 {
_ = values;
return null;
}
// TODO 2: add delta to every element IN PLACE. The caller sees the change, so
// this needs the pointer-capture form of for.
// shiftAll(&data, 5) turns { 1, 2 } into { 6, 7 }
fn shiftAll(values: []i32, delta: i32) void {
_ = values;
_ = delta;
}
// TODO 3: return how many times needle appears in haystack.
// One std.mem function does this in a single line.
// countWord("ab ab cd", "ab") -> 2
fn countWord(haystack: []const u8, needle: []const u8) usize {
_ = haystack;
_ = needle;
return 0;
}
// A tiny stand-in for an assert: print what went wrong and return an error.
fn check(ok: bool, comptime message: []const u8) !void {
if (!ok) {
std.debug.print("CHECK FAILED: {s}\n", .{message});
return error.CheckFailed;
}
}
// --- Build checks: Do not edit below this line. ---
pub fn main() !void {
const sample = [_]i32{ 42, 17, 93, 56, 28 };
try check(findMax(&sample).? == 93, "findMax should return the largest value, 93");
try check(findMax(sample[0..2]).? == 42, "findMax over the first two elements should be 42");
try check(findMax(&.{}) == null, "findMax of an empty slice should be null, not a number");
var data = [_]i32{ 1, 2, 3 };
shiftAll(&data, 5);
try check(data[0] == 6, "shiftAll should change the caller's array in place: data[0] should be 6");
try check(data[2] == 8, "shiftAll should shift every element: data[2] should be 8");
try check(countWord("ab ab cd", "ab") == 2, "countWord should find 2 occurrences of \"ab\"");
try check(countWord("ab ab cd", "zz") == 0, "countWord should return 0 when the needle is absent");
std.debug.print("All checks passed.\n", .{});
std.debug.print("Max: {}\n", .{findMax(&sample).?});
std.debug.print("Shifted: ", .{});
for (data) |v| std.debug.print("{} ", .{v});
std.debug.print("\n", .{});
}Expected output:
All checks passed. Max: 93 Shifted: 6 7 8
Once it passes, try two variations and predict each before running:
- Drop the pointer from the capture. In
shiftAll, changefor (values) |*v| v.* += delta;tofor (values) |v| _ = v + delta;. Predict which check fails before running. It builds — nothing is illegal about computing a value and discarding it — but the caller's array never changes, so thedata[0] should be 6check fails. Mutation lives in the*and the.*, not in the arithmetic. - Seed the max from the wrong place. In
findMax, change the loop fromvalues[1..]tovalues[0..]and remove the empty-slice guard. Predict which check fails. The first two checks still pass — re-examiningvalues[0]is harmless — butfindMax(&.{})now indexes element 0 of an empty slice and the program panics with an index-out-of-bounds instead of returningnull. The guard is what makes the optional return type honest.
Capstone milestone
Milestone — record parsing. The capstone's Record Analyzer splits each input line into pipe-delimited fields and hands the pieces around as []const u8 slices that borrow from one file buffer — no copying. The slice toolkit you just built is that machinery in miniature. Confirm you can pass and iterate borrowed views instead of copies.
Hint: You don't need the full parser yet — this confirms the slice fluency it is built on. In the capstone, splitScalar hands you one field slice at a time, each one a view into the file buffer rather than a copy, which is why the Record struct is only valid while that buffer lives.
- Wrote a function taking []const T so it works with arrays of any size, not a fixed-length array parameter
- Returned an optional from a function whose answer can legitimately be absent, and guarded the empty case
- Mutated a caller's data in place using the for (values) |*v| pointer capture
- Used a std.mem helper (count, indexOf, eql, splitScalar) instead of hand-rolling the search
Key Takeaways
- Slices (
[]T) are a pointer plus a length — safe and efficient - Sentinel-terminated slices (
[:0]const u8) support C interop forloops iterate over slices with optional index and parallel iteration- Use
&valueswith|*v|for mutable iteration std.memprovides splitting, searching, counting, and comparison utilities- Slicing is zero-cost — it creates a new view without copying data
Pro Tip: Prefer slices over arrays in function parameters. A function that takes
[]const i32works with arrays of any size and with other slices, whatever they are a view into. This makes your functions more reusable without any runtime cost — and it keeps working unchanged once Memory And Allocators shows you how to obtain memory at run time, because a slice of that memory is still just a slice.
Next Steps
Now that you can work with slices and iterate over data, it's time to look at what a slice is built from. Next, you'll dive into pointers and references -- Zig's explicit model for reading and mutating data by address.