Skip to content

Control Flow

In Rust, if is an expression — it evaluates to a value. This means you can use it on the right-hand side of a let binding. All branches must produce the same type.

fn main() {
let score = 75;
let grade = if score >= 90 { "A" } else if score >= 75 { "B" } else { "C" };
println!("grade = {}", grade);
}

No parentheses are required around the condition, and curly braces around the body are mandatory.

What the Compiler Says: The Condition Must Be bool

Section titled “What the Compiler Says: The Condition Must Be bool”

Rust has no notion of “truthy” values — no language rule turns a nonzero integer, a non-empty string, or a non-null pointer into true. The condition of an if must be a real bool, or the compiler rejects it before it ever considers what the branches return:

// @expect-error E0308
fn main() {
let x = 5;
if x {
println!("nonzero");
}
}
error[E0308]: mismatched types
--> src/main.rs:4:8
|
4 | if x {
| ^ expected `bool`, found integer
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0308`.

The fix is always a real comparison, such as if x != 0. This is the same E0308 you have already seen for mismatched assignments and returns — the condition of an if is just another place the compiler checks a type against what it expects.

Separately from the condition, the if and else bodies are two expressions that must produce the same type as each other, because the whole if has to resolve to one type when it is used as a value:

// @expect-error E0308
fn main() {
let x = 5;
let y = if x > 0 { "positive" } else { 0 };
println!("{}", y);
}
error[E0308]: `if` and `else` have incompatible types
--> src/main.rs:4:44
|
4 | let y = if x > 0 { "positive" } else { 0 };
| ---------- ^ expected `&str`, found integer
| |
| expected because of this
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0308`.

An if used only as a statement (its value discarded) does not have this restriction — every branch is allowed to be () independently, which is exactly the shape of the next example.

When you do not need the result, use if as a statement:

fn main() {
let n = 7;
if n % 2 == 0 {
println!("even");
} else {
println!("odd");
}
}

loop runs its body indefinitely until an explicit break. Unlike other languages, Rust’s loop can return a value through break.

fn main() {
let mut counter = 0;
let result = loop {
counter += 1;
if counter == 5 {
break counter * 2; // the loop evaluates to this value
}
};
println!("result = {}", result); // 10
}

When loops are nested, a bare break or continue only affects the innermost one. Labels (written 'name: before the loop) let you target an outer loop directly:

fn main() {
let mut found = None;
'outer: for i in 0..5 {
for j in 0..5 {
if i * j == 6 {
found = Some((i, j));
break 'outer;
}
}
}
println!("found = {:?}", found);
}

break 'outer; exits both loops immediately once a match is found — without a label, break alone would only stop the inner for and the outer loop would keep going.

while repeats as long as its condition is true. It cannot return a value via break.

fn main() {
let mut n = 1;
while n < 4 {
print!("{} ", n);
n += 1;
}
println!(); // newline
}

while let — Loop While a Pattern Matches

Section titled “while let — Loop While a Pattern Matches”

while let combines while with pattern matching: it keeps looping as long as the value matches the given pattern, and stops the moment it does not — a common shape for draining a collection:

fn main() {
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
println!("popped: {}", top);
}
}

.pop() returns Option<i32> — Some(value) while the Vec still has elements, None once it is empty. The loop condition is the pattern match: it runs the body while the pattern matches Some(_), and exits the instant .pop() returns None.

for — Iterate Over Collections and Ranges

Section titled “for — Iterate Over Collections and Ranges”

for is the most common loop in Rust. It iterates over any type that implements the Iterator trait — including ranges and arrays.

fn main() {
// Exclusive range: 1, 2, 3
for i in 1..4 {
print!("{} ", i);
}
println!();
// Inclusive range: 1, 2, 3, 4, 5
for i in 1..=5 {
print!("{} ", i);
}
println!();
// Iterating over an array
let fruits = ["apple", "banana", "cherry"];
for fruit in fruits {
print!("{} ", fruit);
}
println!();
}

Prefer for over while with a manual index whenever possible — it is safer and more expressive.

match compares a value against a series of patterns and runs the arm for the first one that fits — patterns can be exact integers or inclusive ranges. The full power of match (enums, structs, guards, bindings) belongs to the Structs, Enums & Matching module; here is just enough to read simple dispatch logic:

fn main() {
let score = 82;
let grade = match score {
90..=100 => "A",
80..=89 => "B",
70..=79 => "C",
_ => "F",
};
println!("grade = {}", grade);
}

Unlike a chain of if/else if, match on an exhaustive set of patterns is checked by the compiler for completeness — the trailing _ => "F" arm here is what makes this particular match exhaustive; the next module covers what happens (and what the compiler says) when you leave one out.

let else binds a pattern like a normal let, but if the pattern does not match, its else block runs instead — and that block must diverge (return, break, continue, or panic!), because the rest of the function assumes the pattern succeeded:

fn parse_or_default(input: &str) -> i32 {
let Ok(n) = input.parse::<i32>() else {
return 0;
};
n * 2
}
fn main() {
println!("{}", parse_or_default("21")); // 42
println!("{}", parse_or_default("nope")); // 0
}

Compare this to while let above: both extract a value from a pattern, but while let loops while the pattern keeps matching, while let else runs once and demands the pattern match immediately, with an explicit escape hatch when it does not.

Starting in edition 2024, you can combine multiple let pattern matches and boolean conditions with && directly inside an if (or while) — no nested if let needed:

fn main() {
let a: Option<i32> = Some(5);
if let Some(x) = a && x > 0 {
println!("positive: {}", x);
} else {
println!("not positive");
}
}

This is a 2024-only feature. Compiling the exact same if let Some(x) = a && x > 0 under edition 2021 is rejected outright, with a message naming the edition requirement directly:

error: let chains are only allowed in Rust 2024 or later
--> src/main.rs:3:8
|
3 | if let Some(x) = a && x > 0 {
| ^^^^^^^^^^^^^^^

Before let chains, the same logic needed a nested if let with the extra condition inside it (if let Some(x) = a { if x > 0 { ... } }) or a matches!-style combination — both noticeably more nested than the 2024 version above.

fn main() {
// if as an expression
let score = 75;
let grade = if score >= 90 { "A" } else if score >= 75 { "B" } else { "C" };
println!("grade = {}", grade);
// loop returning a value via break
let mut counter = 0;
let result = loop {
counter += 1;
if counter == 5 {
break counter * 2;
}
};
println!("loop result = {}", result);
// while loop
let mut n = 1;
while n < 4 {
print!("{} ", n);
n += 1;
}
println!();
// for with a range
for i in 1..=3 {
print!("{} ", i);
}
println!();
// for over an array
let nums = [10, 20, 30];
for val in nums {
print!("{} ", val);
}
println!();
}
flowchart TD
  Start{"what are you
looping over?"}
  Start -->|"a collection or range"| For["for item in collection"]
  Start -->|"a condition, no known count"| While["while condition"]
  Start -->|"a pattern that may stop matching"| WhileLet["while let Some(x) = ..."]
  Start -->|"unknown iterations,
need a value out of the loop"| Loop["loop { ... break value; }"]
  For --> Body["loop body"]
  While --> Body
  WhileLet --> Body
  Loop --> Body
  Body -->|"nested loop, need to exit an outer one"| Label["label the outer loop:
'outer: for/while/loop"]
Choosing a loop construct

Every control-flow construct in this lesson is really answering one question: “given this condition or pattern, which block of code runs next, and does it produce a value I care about?” if and match answer it once, from a condition or a pattern, and can produce a value directly. loop, while, and for answer it repeatedly — loop is the only one that can hand a value back out (via break value), because while and for are built to stop when their condition becomes false, not when a computation is done, and those are different reasons to stop.

Making if and match full expressions (instead of statements, like in C or Java) removes an entire category of “declare a variable above, assign it in every branch below” boilerplate — at the cost of a stricter rule: every branch must agree on a type, which is exactly what the compiler enforces with E0308 when they do not. Let chains buy a flatter, less-nested if let ... && condition at the cost of being a 2024-only feature — code that must also compile on an older edition (or an MSRV predating 2024’s stabilization) cannot use them yet.

  • “Any non-bool value can be used as a condition, like 0/1 in C or truthy/falsy values in Python and JavaScript.” Rust has no truthiness coercion at all — an if condition must already be a bool, full stop, which is exactly the E0308 demonstrated above.
  • “while let and let else do the same thing.” while let repeats its body for as long as a pattern keeps matching. let else matches a pattern exactly once and diverges immediately (return/break/panic!) if it does not — it is for “get this value or bail,” not for looping.
  • “Let chains are just syntax sugar with no real edition restriction.” They are gated to edition 2024 specifically — the exact same source compiles under 2021 only after removing the chained && inside the if let, so this is a genuine compatibility boundary, not merely a style choice.

AI-generated loops sometimes have no reachable break at all — a loop { ... } block where every exit path was meant to be an early return, but one branch was refactored and the return quietly turned into something that does not actually leave the loop, leaving an infinite loop that only shows up under real load.

🐛 Spot the bug

fn wait_for_ready(check: impl Fn() -> bool) {
loop {
if check() {
println!("ready!");
}
}
}
Show the answer

This loop has no break anywhere — once check() returns true it prints "ready!" and immediately loops right back around and checks again, forever, printing "ready!" on every single iteration instead of stopping. The fix is a break in the branch that was supposed to end the wait: if check() { println!("ready!"); break; }. Every hand-written loop should be checked for a break that is actually reachable on every relevant path, not just present somewhere in the source.

What is required for Rust's if to be used as an expression on the right side of let?
How does loop return a value in Rust?
What is the difference between 1..4 and 1..=4?
Which loop construct is most idiomatic in Rust when iterating over a collection?
Does this compile?
fn main() {
    let x = 5;
    if x {
        println!("nonzero");
    }
}
What does break 'outer; do inside a nested loop labeled 'outer: for ... { for ... { ... } }?
What is required for the else block of a let else statement?
let Ok(n) = input.parse::<i32>() else {
    // what must go here?
};