A switch in Zig is an exhaustive selection structure that forces you to consider all possible cases. If you come from C, C++, or Java, you already know the usefulness of this structure, as well as the errors that accidental fallthrough can cause.
It’s useful, yes. But it’s also dangerous. How many times have you forgotten to put a break and the code executed in a cascade (fallthrough), causing a silent bug? Or how many times have you added a new value to an enum and forgotten to update the switch that handled it?
Zig avoids both problems: it does not allow implicit fall-through between branches and requires that all possible values are covered.
Mandatory Exhaustiveness
The fundamental difference in Zig is that the switch must be exhaustive. This means that we are forced to handle every single possible value of the variable we are evaluating.
If we do a switch on a u8 integer (which has 256 possible values) and only define two cases, the code will not compile.
const number: u8 = 5;
// ERROR: switch must handle all possibilities
switch (number) {
1 => std.debug.print("One", .{}),
2 => std.debug.print("Two", .{}),
}To fix this, we have two options: cover all 256 cases (impossible) or use the else clause to cover “the rest”.
switch (number) {
1 => std.debug.print("One", .{}),
2 => std.debug.print("Two", .{}),
else => std.debug.print("Another number", .{}),
}This feature is especially useful with enums. If you add a new state, the compiler points to the exhaustive switch statements that don’t yet handle it.
Branch Syntax
Zig’s syntax uses arrows => instead of case 1: ... break;. Additionally, it eliminates implicit fall-through: only the matching branch is executed.
Value Lists
We can group multiple values on a single line by separating them with commas. This replaces the old pattern of stacking empty case statements.
const letter: u8 = 'e';
switch (letter) {
'a', 'e', 'i', 'o', 'u' => std.debug.print("It's a vowel\n", .{}),
else => std.debug.print("It's a consonant (or symbol)\n", .{}),
}Value Ranges (...)
Zig allows defining inclusive ranges using three dots .... These are useful for categorizing numerical data.
const score: u8 = 85;
switch (score) {
0...49 => std.debug.print("Fail\n", .{}),
50...69 => std.debug.print("Pass\n", .{}),
70...89 => std.debug.print("Good\n", .{}),
90...100 => std.debug.print("Excellent\n", .{}),
else => std.debug.print("Invalid score\n", .{}),
}Note the difference: In for loops, the range is 0..N (exclusive). In switch, the range is 0...N (inclusive).
switch as an Expression
Like we saw with if, the switch in Zig is an expression. This means it can return a value that we assign directly to a variable.
This allows us to turn complex logic into clean, immutable const assignments.
const day = 3;
const day_name = switch (day) {
1 => "Monday",
2 => "Tuesday",
3 => "Wednesday",
4 => "Thursday",
5 => "Friday",
6, 7 => "Weekend",
else => "Invalid day",
};
std.debug.print("Today is {s}\n", .{day_name});Notice the semicolon ; at the end of the closing brace. It is necessary because we are finishing a statement (const ... = ...;).
Value Capture in else
Sometimes, in the else branch, we want to know exactly what value brought us here. Zig allows us to capture that value using vertical bars | |.
const x: u8 = 42;
switch (x) {
0, 10, 20 => std.debug.print("Small round number\n", .{}),
else => |n| std.debug.print("Unsupported number: {d}\n", .{n}),
}Tagged Unions
Although we will look at data structures later, switch is the main tool for working with tagged unions (similar to enums with data in Rust or Swift).
If we have a type that can be an Integer OR Text, we use switch to unpack it safely:
// (Conceptual example)
switch (my_data) {
.integer => |value| std.debug.print("It's a number: {d}", .{value}),
.text => |str| std.debug.print("It's text: {s}", .{str}),
}The compiler checks which variant is active and only allows access to the field captured by the corresponding branch.