Beginner

Control Flow in Zig

Learn how to control program flow in Zig with if/else expressions, while and for loops, switch statements, and labeled break/continue using Docker-ready examples

Control flow is how a program decides what to do next: which branch to take, how many times to repeat work, and when to stop. Zig keeps this deliberately simple and explicit — there are no hidden jumps, no exceptions unwinding the stack, and no surprising function calls behind an operator. What you read is what runs.

As a multi-paradigm systems language, Zig gives you the familiar structured tools — if, while, for, and switch — but with a twist that distinguishes it from C: most of these constructs are expressions, not just statements. An if or a switch can produce a value, which is why Zig has no ternary operator and rarely needs one. Loops can also yield values through labeled blocks.

In this tutorial you will learn how to branch with if/else, iterate with while and for, dispatch with exhaustive switch statements, and steer loops precisely with break, continue, and labels. Every example is self-contained and runnable with Docker.

Conditionals with if/else

Zig’s if works as both a statement and an expression. Because it can return a value, the pattern const max = if (a > b) a else b; replaces the ternary operator found in other C-family languages. if also has a special form for capturing the contents of an optional.

Create a file named control_flow_if.zig:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const std = @import("std");

pub fn main() void {
    const temperature: i32 = 18;

    // Basic if / else if / else
    if (temperature > 25) {
        std.debug.print("It's warm\n", .{});
    } else if (temperature >= 15) {
        std.debug.print("It's mild\n", .{});
    } else {
        std.debug.print("It's cold\n", .{});
    }

    // if used as an expression (Zig has no ternary operator)
    const a: i32 = 7;
    const b: i32 = 12;
    const max = if (a > b) a else b;
    std.debug.print("max = {d}\n", .{max});

    // if with an optional: capture the inner value when non-null
    const maybe_value: ?i32 = 42;
    if (maybe_value) |value| {
        std.debug.print("got value {d}\n", .{value});
    } else {
        std.debug.print("no value\n", .{});
    }
}

Loops: while and for

Zig has two looping keywords. while repeats as long as a condition holds and supports an optional continue expression (: (i += 1)) that runs after every iteration — handy for keeping the update logic in one place. for iterates over arrays, slices, and integer ranges, and can bind an index alongside each element.

Create a file named control_flow_loops.zig:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const std = @import("std");

pub fn main() void {
    // while loop with a manual counter
    var i: u32 = 1;
    while (i <= 3) {
        std.debug.print("while: {d}\n", .{i});
        i += 1;
    }

    // while with a continue expression (runs after each iteration)
    var sum: u32 = 0;
    var n: u32 = 1;
    while (n <= 5) : (n += 1) {
        sum += n;
    }
    std.debug.print("sum 1..5 = {d}\n", .{sum});

    // for loop over an array of string slices
    const colors = [_][]const u8{ "red", "green", "blue" };
    for (colors) |color| {
        std.debug.print("color: {s}\n", .{color});
    }

    // for loop with an index using the 0.. range syntax
    for (colors, 0..) |color, index| {
        std.debug.print("{d} -> {s}\n", .{ index, color });
    }

    // for over an integer range
    for (0..3) |k| {
        std.debug.print("range: {d}\n", .{k});
    }
}

Switch statements

Zig’s switch is exhaustive: every possible value must be handled, or you must provide an else branch — the compiler enforces it. Arms can match single values, comma-separated lists of values, or inclusive ranges with .... Like if, switch is also an expression, so it can return a value directly.

Create a file named control_flow_switch.zig:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const std = @import("std");

pub fn main() void {
    const day: u32 = 3;

    // switch used as a statement
    switch (day) {
        1 => std.debug.print("Monday\n", .{}),
        2 => std.debug.print("Tuesday\n", .{}),
        3 => std.debug.print("Wednesday\n", .{}),
        4, 5 => std.debug.print("Almost weekend\n", .{}),
        else => std.debug.print("Weekend\n", .{}),
    }

    // switch used as an expression with inclusive ranges
    const score: u32 = 85;
    const grade = switch (score) {
        90...100 => "A",
        80...89 => "B",
        70...79 => "C",
        60...69 => "D",
        else => "F",
    };
    std.debug.print("score {d} -> grade {s}\n", .{ score, grade });

    // switch matching multiple values in a single arm
    const ch: u8 = 'e';
    const kind = switch (ch) {
        'a', 'e', 'i', 'o', 'u' => "vowel",
        else => "consonant",
    };
    std.debug.print("'{c}' is a {s}\n", .{ ch, kind });
}

Loop control: break, continue, and labels

break exits a loop early and continue skips to the next iteration. Zig adds a powerful refinement: labels. A labeled block can break with a value, turning a loop into an expression. Labeled loops let break and continue target an outer loop directly, which removes the need for flag variables in nested iterations.

Create a file named control_flow_advanced.zig:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const std = @import("std");

pub fn main() void {
    // break and continue inside a loop
    var i: u32 = 0;
    while (i < 10) : (i += 1) {
        if (i % 2 == 0) continue; // skip even numbers
        if (i > 7) break; // stop once past 7
        std.debug.print("odd: {d}\n", .{i});
    }

    // labeled block: break out with a value
    const numbers = [_]i32{ 4, 8, 15, 16, 23, 42 };
    const first_big = blk: {
        for (numbers) |num| {
            if (num > 20) break :blk num;
        }
        break :blk -1;
    };
    std.debug.print("first > 20: {d}\n", .{first_big});

    // labeled continue to skip to the next outer iteration
    var pairs: u32 = 0;
    outer: for (0..3) |x| {
        for (0..3) |y| {
            if (x == y) continue :outer;
            pairs += 1;
        }
    }
    std.debug.print("counted pairs: {d}\n", .{pairs});
}

Running with Docker

Run each example with the official Zig image — no local Zig installation required.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Pull the Zig image
docker pull kassany/alpine-ziglang:0.14.0

# Run the conditionals example
docker run --rm -v $(pwd):/app -w /app kassany/alpine-ziglang:0.14.0 zig run control_flow_if.zig

# Run the loops example
docker run --rm -v $(pwd):/app -w /app kassany/alpine-ziglang:0.14.0 zig run control_flow_loops.zig

# Run the switch example
docker run --rm -v $(pwd):/app -w /app kassany/alpine-ziglang:0.14.0 zig run control_flow_switch.zig

# Run the loop control example
docker run --rm -v $(pwd):/app -w /app kassany/alpine-ziglang:0.14.0 zig run control_flow_advanced.zig

Expected Output

control_flow_if.zig:

It's mild
max = 12
got value 42

control_flow_loops.zig:

while: 1
while: 2
while: 3
sum 1..5 = 15
color: red
color: green
color: blue
0 -> red
1 -> green
2 -> blue
range: 0
range: 1
range: 2

control_flow_switch.zig:

Wednesday
score 85 -> grade B
'e' is a vowel

control_flow_advanced.zig:

odd: 1
odd: 3
odd: 5
odd: 7
first > 20: 23
counted pairs: 3

Key Concepts

  • if is an expression — Zig has no ternary operator because const x = if (cond) a else b; does the job directly.
  • Optional captureif (optional) |value| { ... } unwraps an optional only when it is non-null, making null handling explicit and safe.
  • Two loop keywords — use while for condition-driven loops and for to iterate over arrays, slices, and 0..n integer ranges.
  • Continue expressions — the while (cond) : (update) form keeps the per-iteration update next to the condition, so the loop body stays focused.
  • Exhaustive switch — the compiler requires every case to be covered (or an else), eliminating an entire class of missed-case bugs.
  • Switch arms are flexible — match single values, value lists (4, 5), or inclusive ranges (80...89), and let switch return a value as an expression.
  • Labels are first-class — labeled blocks break with a value, and labeled loops let break/continue target an outer loop without flag variables.
  • No hidden control flow — every branch and jump is visible in the source; Zig never inserts implicit function calls or exceptions behind your back.

Running Today

All examples can be run using Docker:

docker pull kassany/alpine-ziglang:0.14.0
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining