Chapter 02Control Flow Essentials

Control Flow Essentials

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 if expressions (with optional payload capture) to derive values while handling missing data paths explicitly.
  • Combine while/for loops with labeled break/continue to manage nested iteration and exit conditions clearly.
  • Apply switch to enumerate exhaustive decision tables, including ranges, multiple values, and enumerations.
  • Leverage loop else clauses 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:

graph LR SOURCE["Source Code<br/>.zig files"] TOKENS["Token Stream"] AST["AST<br/>(Ast.zig)"] ZIR["ZIR<br/>(Zir)"] AIR["AIR<br/>(Air.zig)"] MIR["MIR<br/>(codegen.AnyMir)"] MACHINE["Machine Code"] SOURCE -->|"tokenizer.zig"| TOKENS TOKENS -->|"Parse.zig"| AST AST -->|"AstGen.zig"| ZIR ZIR -->|"Sema.zig"| AIR AIR -->|"codegen.generateFunction()"| MIR MIR -->|"codegen.emitFunction()"| MACHINE
IR StageRepresentationKey PropertiesPurpose for Control Flow
TokensFlat token streamRaw lexical analysisRecognizes if, while, switch keywords
ASTTree structureSyntax-correct, untypedPreserves structure of nested control flow
ZIRInstruction-based IRUntyped, single SSA form per declarationLowers control flow to blocks and branches
AIRInstruction-based IRFully typed, single SSA form per functionType-checked branches with known outcomes
MIRBackend-specific IRNear machine code, register-allocatedConverts 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.

Zig
// 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 });
    }
}
Run
Shell
$ zig run branching.zig
Output
Shell
sample 0: positive
sample 1: zero
sample 2: missing
sample 3: negative

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

graph TB IfNode["if (cond) then_expr else else_expr"] --> EvalCond["Evaluate condition"] EvalCond --> CheckRL["Result location needed?"] CheckRL -->|No RL| SimpleIf["Generate condbr<br/>Two blocks with breaks"] CheckRL -->|With RL| BlockIf["Generate block_inline<br/>Shared result pointer"] SimpleIf --> ThenBlock["then_block:<br/>eval then_expr<br/>break value"] SimpleIf --> ElseBlock["else_block:<br/>eval else_expr<br/>break value"] BlockIf --> AllocResult["alloc_inferred"] BlockIf --> ThenBlockRL["then_block:<br/>write to result ptr"] BlockIf --> ElseBlockRL["else_block:<br/>write to result ptr"]

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.

Zig
// 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;
            }
        }
    }
}
Run
Shell
$ zig run loop_labels.zig
Output
Shell
first all-even row: 1
found target value at row 1, column 1

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

graph TB Loop["while/for"] --> LoopLabel["Create labeled block"] LoopLabel --> Condition["Generate loop condition"] Condition --> Body["Generate loop body"] Body --> Continue["Generate continue expression"] LoopLabel --> BreakTarget["break_block target"] Body --> ContinueTarget["continue_block target"] Continue --> CondCheck["Jump back to condition"]

When you write outer: while (x < 10), the compiler creates:

  • break_block: The target for break :outer statements—exits the loop
  • continue_block: The target for continue :outer statements—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.

Zig
// 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 });
}
Run
Shell
$ zig run switch_examples.zig
Output
Shell
0: no progress
2: warming up
5: halfway there
8: almost done
10: perfect run
12: out of range
mode safe -> factor 16

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

graph TB Switch["switch (target) { ... }"] --> EvalTarget["Evaluate target operand"] EvalTarget --> Prongs["Process switch prongs"] Prongs --> Multi["Multiple cases per prong"] Prongs --> Range["Range cases (a...b)"] Prongs --> Capture["Capture payload"] Multi --> SwitchBlock["Generate switch_block"] Range --> SwitchBlock Capture --> SwitchBlock SwitchBlock --> ExtraData["Store in extra:<br/>- prong count<br/>- case items<br/>- prong bodies"]

Exhaustiveness checking happens during semantic analysis (after ZIR generation) when types are known. The compiler verifies that:

  • All enum tags are covered (or an else branch 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.

Zig
// 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 },
    );
}
Run
Shell
$ zig run script_runner.zig
Output
Shell
stopped at step 3 with total 7

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

Zig
// 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});
}
Run
Shell
$ zig run range_scan.zig
Output
Shell
first negative at index 3
encountered zero, breaking out
sum of even prefix values = 16

The 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/continue explicit and avoids sentinel variables.
  • switch must remain exhaustive—if you rely on else, document the invariant with comments or unreachable so future cases are not silently ignored.
  • Loop else clauses are evaluated only when the loop exits naturally; make sure your break paths return values to avoid falling back to unintended defaults.

Exercises

  • Extend branching.zig with a third branch that formats values greater than 100 differently, confirming the if expression still returns a single string.
  • Adapt loop_labels.zig to return the exact coordinates as a struct via break :outer, then print them from main.
  • Modify script_runner.zig to parse characters at runtime (for example, from a byte slice) and add a new command that resets the total, ensuring the switch stays exhaustive.

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.