Overview
Chapter 1 established the building blocks for running a Zig program and working with data; now we turn those values into decisions by walking through the language’s control-flow primitives, as described in #if. Control flow in Zig is expression-oriented, so choosing a branch or looping often produces a value instead of merely guiding execution.
We explore the semantics behind loops, labeled flow, and switch, emphasizing how break, continue, and else clauses communicate intent in both safe and release builds; see #While, #for, and #switch.
Learning Goals
- Use
ifexpressions (with optional payload capture) to derive values while handling missing data paths explicitly. - Combine
while/forloops with labeledbreak/continueto manage nested iteration and exit conditions clearly. - Apply
switchto enumerate exhaustive decision tables, including ranges, multiple values, and enumerations. - Leverage loop
elseclauses and labeled breaks to return values directly from iteration constructs.
What Happens to Control Flow Code
Before diving into control flow syntax, it is helpful to understand what the compiler does with your if, while, and switch statements. Zig transforms source code through multiple intermediate representations (IRs), each serving a specific purpose:
| IR Stage | Representation | Key Properties | Purpose for Control Flow |
|---|---|---|---|
| Tokens | Flat token stream | Raw lexical analysis | Recognizes if, while, switch keywords |
| AST | Tree structure | Syntax-correct, untyped | Preserves structure of nested control flow |
| ZIR | Instruction-based IR | Untyped, single SSA form per declaration | Lowers control flow to blocks and branches |
| AIR | Instruction-based IR | Fully typed, single SSA form per function | Type-checked branches with known outcomes |
| MIR | Backend-specific IR | Near machine code, register-allocated | Converts to jumps and conditional instructions |
The control flow constructs you write—if expressions, switch statements, labeled loops—are systematically lowered through these stages. By the time your code reaches machine code, a switch has become a jump table, and a while loop is a conditional branch instruction. The diagrams in this chapter show how this lowering happens at the ZIR stage, where control flow becomes explicit blocks and branches.
Core Control Structures
Control flow in Zig treats blocks and loops as expressions, which means each construct can yield a value and participate directly in assignment or return statements. This section steps through conditionals, loops, and switch, showing how each fits into the expression model while keeping readability high, as described in #Blocks.
Conditionals as Expressions
if evaluates to the value of whichever branch runs, and the optional capture form (if (opt) |value|) is a concise way to unwrap optionals without shadowing earlier names. Nested labeled blocks (blk: { … }) let you choose among multiple outcomes while still returning a single value.
// File: chapters-data/code/02__control-flow-essentials/branching.zig
// Demonstrates Zig's control flow and optional handling capabilities
const std = @import("std");
/// Determines a descriptive label for an optional integer value.
/// Uses labeled blocks to handle different numeric cases cleanly.
/// Returns a string classification based on the value's properties.
fn chooseLabel(value: ?i32) []const u8 {
// Unwrap the optional value using payload capture syntax
return if (value) |v| blk: {
// Check for zero first
if (v == 0) break :blk "zero";
// Positive numbers
if (v > 0) break :blk "positive";
// All remaining cases are negative
break :blk "negative";
} else "missing"; // Handle null case
}
pub fn main() !void {
// Array containing both present and absent (null) values
const samples = [_]?i32{ 5, 0, null, -3 };
// Iterate through samples with index capture
for (samples, 0..) |item, index| {
// Classify each sample value
const label = chooseLabel(item);
// Display the index and corresponding label
std.debug.print("sample {d}: {s}\n", .{ index, label });
}
}
$ zig run branching.zigsample 0: positive
sample 1: zero
sample 2: missing
sample 3: negativeThe function returns a []const u8 because the if expression itself produces the string, stressing how expression-oriented branching keeps call sites compact. The samples loop shows that for can iterate with an index tuple (item, index) yet still rely on the upstream expression to format output.
How if-else Expressions Lower to ZIR
When the compiler encounters an if expression, it transforms it into blocks and conditional branches in ZIR (Zig Intermediate Representation). The exact lowering depends on whether a result location is needed; see result location:
When you write const result = if (x > 0) "positive" else "negative", the compiler creates two blocks (one for each branch) and uses break statements to return the chosen value. This is why if expressions can participate in assignments—they compile to blocks that yield values through their break statements.
While and For Loops with Labels
Loops in Zig can deliver values directly by pairing a break result with the loop’s else clause, which fires when execution completes without breaking. Labeled loops (outer: while (…)) coordinate nested iteration so you can exit early or skip work without temporary booleans.
// File: chapters-data/code/02__control-flow-essentials/loop_labels.zig
// Demonstrates labeled loops and while-else constructs in Zig
const std = @import("std");
/// Searches for the first row where both elements are even numbers.
/// Uses a while loop with continue statements to skip invalid rows.
/// Returns the zero-based index of the matching row, or null if none found.
fn findAllEvenPair(rows: []const [2]i32) ?usize {
// Track current row index during iteration
var row: usize = 0;
// while-else construct: break provides value, else provides fallback
const found = while (row < rows.len) : (row += 1) {
// Extract current pair for examination
const pair = rows[row];
// Skip row if first element is odd
if (@mod(pair[0], 2) != 0) continue;
// Skip row if second element is odd
if (@mod(pair[1], 2) != 0) continue;
// Both elements are even: return this row's index
break row;
} else null; // No matching row found after exhausting all rows
return found;
}
pub fn main() !void {
// Test data containing pairs of integers with mixed even/odd values
const grid = [_][2]i32{
.{ 3, 7 }, // Both odd
.{ 2, 4 }, // Both even (target)
.{ 5, 6 }, // Mixed
};
// Search for first all-even pair and report result
if (findAllEvenPair(&grid)) |row| {
std.debug.print("first all-even row: {d}\n", .{row});
} else {
std.debug.print("no all-even rows\n", .{});
}
// Demonstrate labeled loop for multi-level break control
var attempts: usize = 0;
// Label the outer while loop to enable breaking from nested for loop
outer: while (attempts < grid.len) : (attempts += 1) {
// Iterate through columns of current row with index capture
for (grid[attempts], 0..) |value, column| {
// Check if target value is found
if (value == 4) {
// Report location of target value
std.debug.print(
"found target value at row {d}, column {d}\n",
.{ attempts, column },
);
// Break out of both loops using the outer label
break :outer;
}
}
}
}
$ zig run loop_labels.zigfirst all-even row: 1
found target value at row 1, column 1The while loop’s else null captures the "no match" case without extra state, and the labeled break :outer instantly exits both loops once the target is found. This pattern keeps state handling tight while remaining explicit about the control transfer.
How Loops Lower to ZIR
Loops are transformed into labeled blocks with explicit break and continue targets. This is what makes labeled breaks and loop else clauses possible:
When you write outer: while (x < 10), the compiler creates:
- break_block: The target for
break :outerstatements—exits the loop - continue_block: The target for
continue :outerstatements—jumps to the next iteration - Loop body: Contains your code, with access to both targets
This is why you can nest loops and use labeled breaks to exit to a specific level—each loop label creates its own break_block in ZIR. The loop else clause is attached to the break_block and only executes when the loop completes without breaking.
for Exhaustive Decisions
switch checks values exhaustively—covering literals, ranges, and enums—and the compiler enforces totality unless you provide an else branch. Combining switch with helper functions is a clean way to centralize categorization logic.
// File: chapters-data/code/02__control-flow-essentials/switch_examples.zig
// Import the standard library for I/O operations
const std = @import("std");
// Define an enum representing different compilation modes
const Mode = enum { fast, safe, tiny };
/// Converts a numeric score into a descriptive text message.
/// Demonstrates switch expressions with ranges, multiple values, and catch-all cases.
/// Returns a string literal describing the score's progress level.
fn describeScore(score: u8) []const u8 {
return switch (score) {
0 => "no progress", // Exact match for zero
1...3 => "warming up", // Range syntax: matches 1, 2, or 3
4, 5 => "halfway there", // Multiple discrete values
6...9 => "almost done", // Range: matches 6 through 9
10 => "perfect run", // Maximum valid score
else => "out of range", // Catch-all for any other value
};
}
pub fn main() !void {
// Array of test scores to demonstrate switch behavior
const samples = [_]u8{ 0, 2, 5, 8, 10, 12 };
// Iterate through each score and print its description
for (samples) |score| {
std.debug.print("{d}: {s}\n", .{ score, describeScore(score) });
}
// Demonstrate switch with enum values
const mode: Mode = .safe;
// Switch on enum to assign different numeric factors based on mode
// All enum cases must be handled (exhaustive matching)
const factor = switch (mode) {
.fast => 32, // Optimization for speed
.safe => 16, // Balanced mode
.tiny => 4, // Optimization for size
};
// Print the selected mode and its corresponding factor
std.debug.print("mode {s} -> factor {d}\n", .{ @tagName(mode), factor });
}
$ zig run switch_examples.zig0: no progress
2: warming up
5: halfway there
8: almost done
10: perfect run
12: out of range
mode safe -> factor 16Every switch must account for all possibilities—once every tag is covered, the compiler verifies there is no missing case. Enumerations eliminate magic numbers while still letting you branch on compile-time-known variants.
How Expressions Lower to ZIR
The compiler transforms switch statements into a structured block that handles all cases exhaustively. Range cases, multiple values per prong, and payload captures are all encoded in the ZIR representation:
Exhaustiveness checking happens during semantic analysis (after ZIR generation) when types are known. The compiler verifies that:
- All enum tags are covered (or an
elsebranch exists) - Integer ranges don’t overlap
- No unreachable prongs exist
This is why you cannot accidentally forget a case in a switch over an enum—the type system ensures totality at compile time. Range syntax like 0…5 is encoded in the ZIR as a range case, not as individual values.
Workflow Patterns
Combining these constructs unlocks more expressive pipelines: loops gather or filter data, switch routes actions, and loop labels keep nested flows precise without introducing mutable sentinels. This section chains the primitives into reusable patterns you can adapt for parsing, simulation, or state machines.
Script Processing with Values
This example interprets a mini instruction stream, using a labeled for loop to maintain a running total and stop when a threshold is reached. The switch handles command dispatch, including a deliberate unreachable when an unknown tag appears during development.
// File: chapters-data/code/02__control-flow-essentials/script_runner.zig
// Demonstrates advanced control flow: switch expressions, labeled loops,
// and early termination based on threshold conditions
const std = @import("std");
/// Enumeration of all possible action types in the script processor
const Action = enum { add, skip, threshold, unknown };
/// Represents a single processing step with an associated action and value
const Step = struct {
tag: Action,
value: i32,
};
/// Contains the final state after script execution completes or terminates early
const Outcome = struct {
index: usize, // Step index where processing stopped
total: i32, // Accumulated total at termination
};
/// Maps single-character codes to their corresponding Action enum values.
/// Returns .unknown for unrecognized codes to maintain exhaustive handling.
fn mapCode(code: u8) Action {
return switch (code) {
'A' => .add,
'S' => .skip,
'T' => .threshold,
else => .unknown,
};
}
/// Executes a sequence of steps, accumulating values and checking threshold limits.
/// Processing stops early if a threshold step finds the total meets or exceeds the limit.
/// Returns an Outcome containing the stop index and final accumulated total.
fn process(script: []const Step, limit: i32) Outcome {
// Running accumulator for add operations
var total: i32 = 0;
// for-else construct: break provides early termination value, else provides completion value
const stop = outer: for (script, 0..) |step, index| {
// Dispatch based on the current step's action type
switch (step.tag) {
// Add operation: accumulate the step's value to the running total
.add => total += step.value,
// Skip operation: bypass this step without modifying state
.skip => continue :outer,
// Threshold check: terminate early if limit is reached or exceeded
.threshold => {
if (total >= limit) break :outer Outcome{ .index = index, .total = total };
// Threshold not met: continue to next step
continue :outer;
},
// Safety assertion: unknown actions should never appear in validated scripts
.unknown => unreachable,
}
} else Outcome{ .index = script.len, .total = total }; // Normal completion after all steps
return stop;
}
pub fn main() !void {
// Define a script sequence demonstrating all action types
const script = [_]Step{
.{ .tag = mapCode('A'), .value = 2 }, // Add 2 → total: 2
.{ .tag = mapCode('S'), .value = 0 }, // Skip (no effect)
.{ .tag = mapCode('A'), .value = 5 }, // Add 5 → total: 7
.{ .tag = mapCode('T'), .value = 6 }, // Threshold check (7 >= 6: triggers early exit)
.{ .tag = mapCode('A'), .value = 10 }, // Never executed due to early termination
};
// Execute the script with a threshold limit of 6
const outcome = process(&script, 6);
// Report where execution stopped and the final accumulated value
std.debug.print(
"stopped at step {d} with total {d}\n",
.{ outcome.index, outcome.total },
);
}
$ zig run script_runner.zigstopped at step 3 with total 7The break :outer returns a full Outcome struct, making the loop act like a search that either finds its target or falls back to the loop’s else. The explicit unreachable documents assumptions for future contributors and activates safety checks in debug builds.
Loop Guards and Early Termination
Sometimes the data itself signals when to stop. This walkthrough identifies the first negative number, then accumulates even values until a 0 sentinel appears, demonstrating loop else clauses, labeled continue, and conventional break.
// File: chapters-data/code/02__control-flow-essentials/range_scan.zig
// Demonstrates while loops with labeled breaks and continue statements
const std = @import("std");
pub fn main() !void {
// Sample data array containing mixed positive, negative, and zero values
const data = [_]i16{ 12, 5, 9, -1, 4, 0 };
// Search for the first negative value in the array
var index: usize = 0;
// while-else construct: break provides value, else provides fallback
const first_negative = while (index < data.len) : (index += 1) {
// Check if current element is negative
if (data[index] < 0) break index;
} else null; // No negative value found after scanning entire array
// Report the result of the negative value search
if (first_negative) |pos| {
std.debug.print("first negative at index {d}\n", .{pos});
} else {
std.debug.print("no negatives in sequence\n", .{});
}
// Accumulate sum of even numbers until encountering zero
var sum: i64 = 0;
var count: usize = 0;
// Label the loop to enable explicit break targeting
accumulate: while (count < data.len) : (count += 1) {
const value = data[count];
// Stop accumulation if zero is encountered
if (value == 0) {
std.debug.print("encountered zero, breaking out\n", .{});
break :accumulate;
}
// Skip odd values using labeled continue
if (@mod(value, 2) != 0) continue :accumulate;
// Add even values to the running sum
sum += value;
}
// Display the accumulated sum of even values before zero
std.debug.print("sum of even prefix values = {d}\n", .{sum});
}
$ zig run range_scan.zigfirst negative at index 3
encountered zero, breaking out
sum of even prefix values = 16The two loops showcase complementary exit styles: a loop expression with an else default, and a labeled loop where continue and break spell out which iterations contribute to the running total.
Notes & Caveats
- Prefer labeled loops for clarity any time you have nested iteration; it keeps
break/continueexplicit and avoids sentinel variables. switchmust remain exhaustive—if you rely onelse, document the invariant with comments orunreachableso future cases are not silently ignored.- Loop
elseclauses are evaluated only when the loop exits naturally; make sure yourbreakpaths return values to avoid falling back to unintended defaults.
Exercises
- Extend
branching.zigwith a third branch that formats values greater than 100 differently, confirming theifexpression still returns a single string. - Adapt
loop_labels.zigto return the exact coordinates as a struct viabreak :outer, then print them frommain. - Modify
script_runner.zigto parse characters at runtime (for example, from a byte slice) and add a new command that resets the total, ensuring theswitchstays exhaustive.