Skip to content
Make room for learningYour learning studio
Course outline

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.

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

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

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

zig
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 like i32, Zig rejects i % 2 at compile time and asks you to pick your rounding behaviour explicitly: @mod follows the sign of the divisor (result always non-negative here), while @rem follows 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.

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

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

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.

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

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

  1. var found: ?Pair = null;
  2. if (i * j <= 6) continue;
  3. outer: for (0..5) |i| for (0..5) |j| {
  4. found = Pair{ .i = i, .j = j };
  5. 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:

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

zig
const level: u2 = readLevel();
const name = switch (level) {
  0 => "off",
  1 => "low",
  2 => "high",
};

Practice Exercises

Try these exercises:

  1. 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"
  2. Create a loop that calculates the factorial of a number
  3. Use a switch statement to convert a month number (1-12) to its name
  4. Write nested loops to print a multiplication table
  5. 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.

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

  1. Loosen a boundary. In bandFor, change the first condition from score >= 80 to score > 80. Predict which check fails before running. The bandFor(91) check still passes, but bandFor(80) now falls through to the second branch and returns "mid", so the second check fails with bandFor(80) should be "high" — 80 is on the boundary. That is the difference between > and >= costing you exactly one value.
  2. Move the counter inside the loop. In countAbove, move the var count: usize = 0; declaration from above the for to 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 — the countAbove(scores, 70) should be 3 check 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 break to exit loops early and continue to 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.

Next lesson

Arrays and Structs

Learn Zig arrays and structs — create fixed-size arrays, define structs with methods, and organize data structures for clean, efficient programs.

25 min