TL;DR
Master Zig control flow — use if expressions, while and for loops, and switch expressions with pattern matching to write concise, safe logic.
Key concepts
- zig control flow
- zig switch expression
- zig loops
- zig if statement
- zig pattern matching
Control Flow
Control flow statements let you make decisions and repeat actions in your programs. In this lesson, we'll explore Zig's if statements, loops, and switch expressions.
What You'll Learn
By the end of this lesson you will have built a record classifier: three functions that turn a number into a band label with an if-expression, count how many readings clear a threshold with a loop, and turn a category into a printable name with an exhaustive switch — checked by a battery that pins each boundary to the exact value it should include or exclude.
That classifier is this lesson's report-formatting milestone, and it is the shape of what the record-analyzer does when it prints. The finished tool walks its rows and emits one labelled line per group: a switch picks the label, a loop walks the rows, and a comparison decides who qualifies. You are building that logic here at a size you can hold in your head.
You arrive here able to write functions with typed parameters and return values (Functions). What is new is that a function body can now branch and repeat, which means it can also branch or repeat wrongly in ways that still compile.
Behaviour First: A Decision You Can Watch
Zig's if statements are straightforward but powerful. Click Run and count the lines it prints for a temperature of 25 before reading on. There are three if-constructs here and seven printable branches between them, and you should get two lines, not three — work out which construct stayed silent and why.
const std = @import("std");
pub fn main() !void {
const temperature: i32 = 25;
// Basic if statement
if (temperature > 30) {
std.debug.print("It's hot outside!\n", .{});
}
// If-else
if (temperature < 0) {
std.debug.print("It's freezing!\n", .{});
} else {
std.debug.print("Temperature is above freezing\n", .{});
}
// If-else if-else chain
if (temperature < 0) {
std.debug.print("Freezing\n", .{});
} else if (temperature < 10) {
std.debug.print("Cold\n", .{});
} else if (temperature < 20) {
std.debug.print("Mild\n", .{});
} else {
std.debug.print("Warm\n", .{});
}
}
If as an Expression
In Zig, if can be used as an expression to return values:
const std = @import("std");
pub fn main() !void {
const score: i32 = 85;
// If as an expression
const grade = if (score >= 90)
"A"
else if (score >= 80)
"B"
else if (score >= 70)
"C"
else if (score >= 60)
"D"
else
"F";
std.debug.print("Score: {}, Grade: {s}\n", .{score, grade});
// If with optional values
const maybe_value: ?i32 = 42;
if (maybe_value) |value| {
std.debug.print("Value exists: {}\n", .{value});
} else {
std.debug.print("Value is null\n", .{});
}
}
An if-else chain used as an expression evaluates top to bottom and stops at the first branch that matches. Work out which branch a score of 85 reaches before you read on.
Predict
Trace this if-expression by hand. Which grade does it produce, and which branches does it even look at?
const score: i32 = 85;
const grade = if (score >= 90)
"A"
else if (score >= 80)
"B"
else if (score >= 70)
"C"
else
"F";
std.debug.print("Grade: {s}\n", .{grade});The grade is "B". Zig checks score >= 90 first, finds it false, then checks score >= 80, finds it true, and the whole expression evaluates to "B" — the "C" and "F" branches are never examined. Because exactly one branch supplies a value, the entire chain can be assigned to a single const.
While Loops
While loops continue executing as long as a condition is true. This one is fully worked, with a note on why each form exists. The next two get less help.
const std = @import("std");
pub fn main() !void {
// The plain form puts the advance inside the body. It works, and it is the
// form that lets you forget the advance and loop forever.
var count: i32 = 0;
while (count < 5) {
std.debug.print("Count: {}\n", .{count});
count += 1;
}
// The continue-expression form moves the advance into the header, where it
// runs after EVERY iteration — including one cut short by `continue`. That
// is the whole reason it exists: a `continue` in the body above would skip
// the advance and hang.
var i: i32 = 0;
while (i < 10) : (i += 1) {
// On signed integers the `%` operator is rejected; use @mod (or @rem)
if (@mod(i, 2) == 0) {
std.debug.print("{} is even\n", .{i});
}
}
// A while over an optional loops while it is non-null and hands you the
// unwrapped value — the loop's exit condition and its payload are the same
// thing, so there is no separate emptiness test to forget.
var optional: ?i32 = 5;
while (optional) |value| {
std.debug.print("Value: {}\n", .{value});
optional = if (value > 0) value - 1 else null;
}
}
Signed remainder: The
%operator only works on unsigned integers and non-negative comptime values. For a signed integer likei32, Zig rejectsi % 2at compile time and asks you to pick your rounding behaviour explicitly:@modfollows the sign of the divisor (result always non-negative here), while@remfollows the sign of the dividend. We use@mod(i, 2)above to test for even numbers.
For Loops
A for loop takes the thing to walk rather than a counter to manage, which removes the class of bug
the while notes above were about. Fewer notes here: one is left for you.
const std = @import("std");
pub fn main() !void {
// A range is half-open: 0..5 yields 0,1,2,3,4 — five values, ending one
// before the bound, which is the same convention that makes len one past
// the last index.
for (0..5) |i| {
std.debug.print("Index: {}\n", .{i});
}
const numbers = [_]i32{10, 20, 30, 40, 50};
for (numbers) |num| {
std.debug.print("Number: {}\n", .{num});
}
// Two things are walked in step here, and the second is an OPEN range with
// no end — it simply supplies an index for as long as the first one lasts.
const fruits = [_][]const u8{"apple", "banana", "cherry"};
for (fruits, 0..) |fruit, idx| {
std.debug.print("{}: {s}\n", .{idx, fruit});
}
// Loop over a string (slice of bytes)
const message = "Hello";
for (message) |char| {
std.debug.print("{c} ", .{char});
}
std.debug.print("\n", .{});
}
Your note to write: the last loop walks a string and gets a number for each pass, printed with
{c} rather than {s}. Say what a Zig string actually is, given that a for can walk it one
element at a time. The printed output is the clue.
Break and Continue
No rationale notes on this one — only labels. You have had the reasoning twice; read this one the way you will read code in the wild, and answer the questions underneath from the source alone.
const std = @import("std");
pub fn main() !void {
// Using break
var i: i32 = 0;
while (true) {
if (i >= 5) {
break; // Exit the loop
}
std.debug.print("{} ", .{i});
i += 1;
}
std.debug.print("\n", .{});
// Using continue
for (0..10) |num| {
if (num % 2 == 0) {
continue; // Skip even numbers
}
std.debug.print("{} ", .{num}); // Only prints odd numbers
}
std.debug.print("\n", .{});
// Break with value (returning from loop)
const result = for (0..10) |n| {
if (n * n > 50) {
break n; // Return the value
}
} else 0; // Default value if loop completes without break
std.debug.print("First n where n^2 > 50: {}\n", .{result});
// Try modifying these loops!
}
Two questions to answer before reading on. First: the last loop is a for with an else on
it, which no earlier example had. Given that break n produces a value, what job is the else 0
doing, and when does it run? Second: the middle loop uses % on num, while the while example
earlier had to use @mod instead — both are testing for evenness. What is different about the two
values that makes one spelling legal and the other not? The signed-remainder note above the For
Loops section is the clue.
Switch Expressions
Switch is a powerful pattern matching feature in Zig:
const std = @import("std");
pub fn main() !void {
// Basic switch
const day: u8 = 3;
const day_name = switch (day) {
1 => "Monday",
2 => "Tuesday",
3 => "Wednesday",
4 => "Thursday",
5 => "Friday",
6 => "Saturday",
7 => "Sunday",
else => "Invalid day",
};
std.debug.print("Day {}: {s}\n", .{day, day_name});
// Switch with multiple values
const grade: u8 = 85;
const performance = switch (grade) {
90...100 => "Excellent",
80...89 => "Good",
70...79 => "Average",
60...69 => "Below Average",
else => "Failing",
};
std.debug.print("Grade {}: {s}\n", .{grade, performance});
// Switch with enums
const Color = enum { red, green, blue, yellow };
const color = Color.green;
const hex = switch (color) {
.red => "#FF0000",
.green => "#00FF00",
.blue => "#0000FF",
.yellow => "#FFFF00",
};
std.debug.print("Color {s}: {s}\n", .{@tagName(color), hex});
}
Notice that the enum switch above has no else prong — and it still compiles. The next program looks almost identical but refuses to build at all. Diagnose it before you fix it.
Debug
This program should print a hex code for every colour in the list, ending with '#FFFF00' for yellow. It never runs — the compiler rejects it. Commit a hypothesis about why, then fix it so all four lines print.
const std = @import("std");
const Color = enum { red, green, blue, yellow };
fn toHex(color: Color) []const u8 {
return switch (color) {
.red => "#FF0000",
.green => "#00FF00",
.blue => "#0000FF",
};
}
pub fn main() !void {
const palette = [_]Color{ .red, .green, .blue, .yellow };
for (palette) |color| {
std.debug.print("{s} -> {s}\n", .{ @tagName(color), toHex(color) });
}
}Expected output:
red -> #FF0000 green -> #00FF00 blue -> #0000FF yellow -> #FFFF00
The switch lists three prongs but Color has four fields, and a switch with no else clause must handle every possibility — so the compiler stops with error: switch must handle all possibilities and names the missing value, yellow. Adding .yellow => "#FFFF00" fixes it. You could silence the error with a catch-all else, but exhaustiveness is a feature: it means adding a field to Color later breaks the build instead of quietly returning the wrong colour.
Labeled Blocks and Loops
You can label blocks and loops to control flow more precisely:
const std = @import("std");
pub fn main() !void {
// Labeled break
outer: for (0..5) |i| {
for (0..5) |j| {
if (i * j > 6) {
std.debug.print("Breaking at i={}, j={}\n", .{i, j});
break :outer; // Break out of outer loop
}
std.debug.print("({}, {}) ", .{i, j});
}
std.debug.print("\n", .{});
}
// Labeled continue
outer2: for (0..3) |i| {
for (0..3) |j| {
if (j == 1) {
continue :outer2; // Continue outer loop
}
std.debug.print("({}, {}) ", .{i, j});
}
std.debug.print("\n", .{});
}
// Labeled blocks (return values from blocks)
const result = blk: {
const x = 10;
const y = 20;
break :blk x + y;
};
std.debug.print("Result from block: {}\n", .{result});
}
Labelled break has the strictest ordering requirements of anything in this lesson, because the label, the break, and the work you want done all have to line up. Assemble one yourself — and note that not every pair of lines here is ordered, which is itself worth being able to tell.
Arrange the code
These five statements make up the body of a function that scans a five-by-five grid for the first pair whose product exceeds 6, stops scanning the moment it finds one, and returns it. A struct Pair with usize fields i and j is already declared above. They are shuffled. Two of the five may legitimately go either way round; the rest have exactly one position. Put them in an order that makes the function return i=2 j=4 — then answer what the order is really testing: what does the labelled break end, and what has to have already happened by the time it runs?
var found: ?Pair = null;if (i * j <= 6) continue;outer: for (0..5) |i| for (0..5) |j| {found = Pair{ .i = i, .j = j };break :outer; }; return found;
A labelled break is worth reaching for whenever a search spans nested loops: it says which loop to leave, and that is the difference between finding the first match in the whole grid and finding the first match in each row.
Error Handling in Control Flow
Combine control flow with error handling:
const std = @import("std");
const ValidationError = error{TooSmall, TooBig};
fn validateAge(age: i32) ValidationError!void {
if (age < 0) {
return ValidationError.TooSmall;
}
if (age > 150) {
return ValidationError.TooBig;
}
}
pub fn main() !void {
const ages = [_]i32{-5, 25, 200, 45};
for (ages) |age| {
// If statement with error handling
if (validateAge(age)) {
std.debug.print("Age {} is valid\n", .{age});
} else |err| {
std.debug.print("Age {} is invalid: {}\n", .{age, err});
}
}
}
You have now met the same pipe-delimited capture syntax three times across three lessons, meaning three different things each time. Close the code and see whether you can tell them apart from memory.
Recall
Without scrolling up: the |name| capture syntax has shown up in three places — unwrapping an optional back in Variables and Data Types, handling a returned error back in Functions, and iterating in this lesson. What does the captured value hold in each case?
One syntax, three payloads. if (maybe_value) |value| binds the unwrapped value and only enters the branch when the optional is non-null. if (validateAge(age)) ... else |err| puts the capture on the else side, because that is where the error lives — the success side has no payload to hand over. And for (ages) |age| binds one element per iteration. Read the construct first, then the capture.
Exhaustiveness is the idea in this lesson most worth carrying somewhere else. Here it is in a setting the lesson did not show.
Transfer
This lesson showed that a switch over an enum with no else prong must list every field, and that adding a field later turns a forgotten case into a build error. Now the same switch runs over an integer type instead of an enum — switch (level) with level of type u2, and no else. What carries over, and what changes?
const level: u2 = readLevel();
const name = switch (level) {
0 => "off",
1 => "low",
2 => "high",
};Practice Exercises
Try these exercises:
- Write a program that prints numbers 1-100, but for multiples of 3 print "Fizz", for multiples of 5 print "Buzz", and for multiples of both print "FizzBuzz"
- Create a loop that calculates the factorial of a number
- Use a switch statement to convert a month number (1-12) to its name
- Write nested loops to print a multiplication table
- Create a function that finds the first prime number greater than a given number using a loop and break
Challenge: Write a function that determines if a number is prime using loops and conditional statements. Test it with numbers from 2 to 30.
Build It: A Record Classifier
Reading about control flow is not the same as reaching for the right construct by intent. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out — one wants an if-expression, one wants a loop, one wants an exhaustive switch. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each function until every check passes and it prints All checks passed.
There is no assert you need 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 — no hidden control flow, exactly as Zig prefers it.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each one returns the right value. Run it as-is to see which check fails first, decide which control-flow construct 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");
const Kind = enum { sales, engineering, marketing };
// 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;
}
}
// TODO 1: return a band name for a score using an if-EXPRESSION (one const,
// no separate return statements per branch).
// 80 and above -> "high", 65 to 79 -> "mid", below 65 -> "low"
// bandFor(91) -> "high", bandFor(74) -> "mid", bandFor(58) -> "low"
fn bandFor(score: i32) []const u8 {
_ = score;
return "";
}
// TODO 2: count how many scores are STRICTLY greater than threshold.
// Loop over the slice and keep a running count.
// countAbove(&.{ 91, 74, 58, 83, 66 }, 70) -> 3
fn countAbove(scores: []const i32, threshold: i32) usize {
_ = scores;
_ = threshold;
return 0;
}
// TODO 3: turn a Kind into a report label with an EXHAUSTIVE switch — list
// every field and do NOT add an else prong.
// .sales -> "Sales", .engineering -> "Engineering", .marketing -> "Marketing"
fn labelFor(kind: Kind) []const u8 {
_ = kind;
return "";
}
// --- Build checks: Do not edit below this line. ---
pub fn main() !void {
const scores = [_]i32{ 91, 74, 58, 83, 66 };
try check(std.mem.eql(u8, bandFor(91), "high"), "bandFor(91) should be \"high\"");
try check(std.mem.eql(u8, bandFor(80), "high"), "bandFor(80) should be \"high\" — 80 is on the boundary");
try check(std.mem.eql(u8, bandFor(74), "mid"), "bandFor(74) should be \"mid\"");
try check(std.mem.eql(u8, bandFor(65), "mid"), "bandFor(65) should be \"mid\" — 65 is on the boundary");
try check(std.mem.eql(u8, bandFor(58), "low"), "bandFor(58) should be \"low\"");
try check(countAbove(&scores, 70) == 3, "countAbove(scores, 70) should be 3 (91, 74, 83)");
try check(countAbove(&scores, 91) == 0, "countAbove(scores, 91) should be 0 — the test is strictly greater than");
try check(countAbove(&scores, 0) == 5, "countAbove(scores, 0) should be 5");
try check(std.mem.eql(u8, labelFor(.sales), "Sales"), "labelFor(.sales) should be \"Sales\"");
try check(std.mem.eql(u8, labelFor(.engineering), "Engineering"), "labelFor(.engineering) should be \"Engineering\"");
try check(std.mem.eql(u8, labelFor(.marketing), "Marketing"), "labelFor(.marketing) should be \"Marketing\"");
std.debug.print("All checks passed.\n", .{});
std.debug.print("Bands: {s} {s} {s}\n", .{ bandFor(91), bandFor(74), bandFor(58) });
std.debug.print("Scores above 70: {}\n", .{countAbove(&scores, 70)});
std.debug.print("Label: {s}\n", .{labelFor(.engineering)});
}Expected output:
All checks passed. Bands: high mid low Scores above 70: 3 Label: Engineering
Once it passes, try two variations and predict each before running:
- Loosen a boundary. In
bandFor, change the first condition fromscore >= 80toscore > 80. Predict which check fails before running. ThebandFor(91)check still passes, butbandFor(80)now falls through to the second branch and returns"mid", so the second check fails withbandFor(80) should be "high" — 80 is on the boundary. That is the difference between>and>=costing you exactly one value. - Move the counter inside the loop. In
countAbove, move thevar count: usize = 0;declaration from above theforto the first line of the loop body (and return it from inside). Predict the count before running. The counter is re-created on every iteration, so it can never exceed 1 — thecountAbove(scores, 70) should be 3check fails. Where you declare an accumulator, not how you increment it, is what makes a loop accumulate.
Capstone milestone
Milestone — report formatting. The capstone's Record Analyzer prints a category report: one labelled row per group, with each value classified and counted. The classifier you just built is that formatting logic in miniature — a switch that turns a category into a label, and a loop that counts what qualifies. Confirm you can drive output from control flow.
Hint: You don't need the full report yet — this confirms the branch-and-loop fluency the formatter is built on. In the capstone, a switch picks the output format (table, CSV, JSON) and a loop walks the rows, printing one line each.
- Used an if-expression to turn a number into one of several labels, assigned to a single const
- Used a loop with an accumulator declared outside the loop to count matching items
- Used an exhaustive switch over an enum — every field listed, no else prong
- Confirmed a strictly-greater-than test excludes the boundary value, while >= includes it
Key Takeaways
- If statements can be used as expressions to return values
- While loops continue until a condition is false; for loops iterate over ranges and slices
- Use
breakto exit loops early andcontinueto skip to the next iteration - Switch expressions provide exhaustive pattern matching
- Labeled blocks and loops allow precise control flow in nested structures
- Control flow works seamlessly with error handling using if-else with errors
Next Steps
Now that you understand control flow, let's explore Zig's data structures including arrays and structs.
Ready to try it? Continue in your learning studio to practice these ideas, save your progress, and experiment with code.