Skip to main content
Rust · lesson 6 of 30
Rust beginner Lesson 6 of 30

Control Flow in Rust

Master if/else expressions, loop, while, for, ranges, and breaking with values in Rust.

By SkillByExample · Updated

if / else as an Expression

Unlike C or Java, if/else in Rust is an expression — it produces a value that can be assigned to a variable or returned from a function. This eliminates the need for a separate ternary operator and makes code more compositional. Both arms of an if expression must produce the same type; mismatched types are a compile error.

fn main() {
    let number = 7;

    // Statement form — used for side effects
    if number < 10 {
        println!("single digit");
    } else if number < 100 {
        println!("double digit");
    } else {
        println!("three or more digits");
    }

    // Expression form — both arms produce the same type (&str here)
    let description = if number % 2 == 0 { "even" } else { "odd" };
    println!("{} is {}", number, description);

    // Used directly in a let binding — replaces the ternary operator
    let abs = if number >= 0 { number } else { -number };
    println!("abs = {}", abs);
}
$ rustc control_flow_if.rs && ./control_flow_if
single digit
7 is odd
abs = 7

loop — Infinite Loop

loop runs forever until an explicit break. It is the idiomatic way to express “keep trying until a condition is met” — for example, retrying an operation or polling for input. The key advantage over while true is that loop can return a value, and the compiler recognises it as a type that never naturally falls through, which helps with type inference.

fn main() {
    let mut counter = 0;

    loop {
        counter += 1;
        if counter == 5 {
            break; // exit the loop
        }
    }

    println!("counter = {}", counter);
}
$ rustc control_flow_loop.rs && ./control_flow_loop
counter = 5

Returning a Value from loop

Pass a value to break to return it from the loop expression. This is useful for retry loops where you want to capture the result of the successful attempt.

fn main() {
    let mut attempt = 0;

    // The result of the loop expression is whatever value was passed to break
    let result = loop {
        attempt += 1;
        if attempt * attempt > 50 {
            break attempt; // break with a value — this becomes the loop expression's value
        }
    };

    println!("first n where n² > 50: {}", result);
}
$ rustc control_flow_loopval.rs && ./control_flow_loopval
first n where n² > 50: 8

Loop Labels

Labels ('label:) let you break or continue an outer loop from within a nested loop, which is cleaner than managing boolean flags to signal early exit.

fn main() {
    'outer: for x in 0..5 {
        for y in 0..5 {
            if x + y == 6 {
                println!("breaking outer at x={}, y={}", x, y);
                break 'outer; // exits the outer loop entirely
            }
        }
    }
}
$ rustc control_flow_labels.rs && ./control_flow_labels
breaking outer at x=2, y=4

while Loop

while executes its body as long as a condition is true. It is the right tool when the number of iterations is not known upfront but the termination condition is a simple boolean expression.

fn main() {
    let mut n = 1;

    while n < 100 {
        n *= 2; // keep doubling
    }

    println!("first power of 2 >= 100: {}", n);
}
$ rustc control_flow_while.rs && ./control_flow_while
first power of 2 >= 100: 128

while let is a pattern-matching variant that loops as long as a destructuring pattern matches. It is the idiomatic way to consume an iterator or drain a stack:

fn main() {
    let mut stack = vec![1, 2, 3];

    // Loops until stack.pop() returns None (when the vec is empty)
    while let Some(top) = stack.pop() {
        println!("{}", top);
    }
}
$ rustc control_flow_whilelet.rs && ./control_flow_whilelet
3
2
1

for Loop and Iterators

for iterates over anything that implements the IntoIterator trait — arrays, vectors, ranges, hash maps, and more. It is the most idiomatic loop in Rust because it removes the possibility of off-by-one errors and out-of-bounds indexing entirely. You never manage an index manually.

fn main() {
    let fruits = ["apple", "banana", "cherry"];

    // &fruits borrows the array — fruits is still usable after the loop
    for fruit in &fruits {
        println!("{}", fruit);
    }
}
$ rustc control_flow_for.rs && ./control_flow_for
apple
banana
cherry

Iterating with Index

When you need both the element and its position, use .enumerate(). This is safer and more idiomatic than maintaining a separate counter variable.

fn main() {
    let words = ["zero", "one", "two", "three"];

    // enumerate() yields (index, &element) tuples
    for (i, word) in words.iter().enumerate() {
        println!("{}: {}", i, word);
    }
}
$ rustc control_flow_enumerate.rs && ./control_flow_enumerate
0: zero
1: one
2: two
3: three

Ranges

Ranges give you a concise way to iterate over a sequence of numbers without allocating anything. The exclusive form 0..n is most common; the inclusive form 0..=n is used when the upper bound should be included.

fn main() {
    // Exclusive range: produces 0, 1, 2, 3, 4
    for i in 0..5 {
        print!("{} ", i);
    }
    println!();

    // Inclusive range: produces 0, 1, 2, 3, 4, 5
    for i in 0..=5 {
        print!("{} ", i);
    }
    println!();

    // Reverse iteration
    for i in (0..5).rev() {
        print!("{} ", i);
    }
    println!();

    // Step by a custom amount
    for i in (0..20).step_by(3) {
        print!("{} ", i);
    }
    println!();
}
$ rustc control_flow_ranges.rs && ./control_flow_ranges
0 1 2 3 4 
0 1 2 3 4 5 
4 3 2 1 0 
0 3 6 9 12 15 18 

Consuming vs. Borrowing

How you write the for loop determines whether the collection is borrowed (left intact) or consumed (moved into the loop):

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];

    // Borrow with &numbers — the vec is still usable after the loop
    for n in &numbers {
        print!("{} ", n);
    }
    println!();
    println!("still have: {:?}", numbers);

    // Consume — numbers is moved into the loop and dropped when it ends
    for n in numbers {
        print!("{} ", n);
    }
    println!();
    // println!("{:?}", numbers); // ERROR: value moved into the for loop
}
$ rustc control_flow_consume.rs && ./control_flow_consume
1 2 3 4 5 
still have: [1, 2, 3, 4, 5]
1 2 3 4 5 

continue — Skip to Next Iteration

continue skips the remainder of the current loop body and jumps to the next iteration. Like break, it can be used with labels to affect an outer loop.

fn main() {
    for i in 0..10 {
        if i % 2 == 0 {
            continue; // skip even numbers — jump straight to the next i
        }
        print!("{} ", i);
    }
    println!();
}
$ rustc control_flow_continue.rs && ./control_flow_continue
1 3 5 7 9 

match as Control Flow

match is a powerful expression for branching on values. It is exhaustive — the compiler requires every possible value to be handled. Full coverage is in the pattern matching tutorial; here is a quick look at how it reads as control flow:

fn classify(n: i32) -> &'static str {
    match n {
        i32::MIN..=-1 => "negative",
        0             => "zero",
        1..=9         => "single digit",
        _             => "large",  // _ catches everything else
    }
}

fn main() {
    println!("{}", classify(-5));
    println!("{}", classify(0));
    println!("{}", classify(7));
    println!("{}", classify(100));
}
$ rustc control_flow_match.rs && ./control_flow_match
negative
zero
single digit
large

Practical Example: FizzBuzz

This classic problem demonstrates combining multiple control flow features in a concise, idiomatic way. The match on a tuple of remainders is a particularly clean Rust solution.

fn fizzbuzz(n: u32) -> String {
    // Match on a tuple of both remainders simultaneously
    match (n % 3, n % 5) {
        (0, 0) => String::from("FizzBuzz"), // divisible by both
        (0, _) => String::from("Fizz"),     // divisible by 3 only
        (_, 0) => String::from("Buzz"),     // divisible by 5 only
        _      => n.to_string(),            // not divisible by either
    }
}

fn main() {
    for i in 1..=20 {
        println!("{}", fizzbuzz(i));
    }
}
$ rustc control_flow_fizzbuzz.rs && ./control_flow_fizzbuzz
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
16
17
Fizz
19
Buzz

Summary

ConstructKey Property
if/elseExpression; both arms must have the same type
loopInfinite; can return a value via break value
while condLoops while condition is true
while let patLoops while pattern matches
for x in iterIterates any IntoIterator; most idiomatic
break / continueWorks with optional labels for nested loops
matchPattern-matching expression; must be exhaustive

Practice

  1. Give the two arms of an if/else expression different types: let x = if true { 5 } else { "no" };. What does the compiler say?

    Solution

    error[E0308]: \if` and `else` have incompatible types—expected integer, found `&str`. Because if/else` is an expression with a single result type, every arm must agree; there’s no implicit “pick whichever” the way a dynamic language would allow.

  2. Remove the _ => "large" catch-all arm from classify, leaving only 0, 1..=9, and negative numbers unhandled. What does the compiler say about classify(100)?

    Solution

    error[E0004]: non-exhaustive patterns naming the specific uncovered ranges (here, everything below i32::MIN..=-1 — already covered in the real lesson — and above 9). match must be provably exhaustive at compile time; the compiler doesn’t wait to find out at runtime whether every case was handled, unlike a switch in most other languages that silently falls through.

  3. In the nested-loop label example, replace break 'outer; with continue 'outer;. Predict how the printed output changes.

    Solution

    With break 'outer, the loop exits entirely the first time the condition is hit. With continue 'outer, the outer loop instead moves on to its next iteration of x — so using if y == 1 { continue 'outer; } inside a 0..3/0..3 nested loop prints only the y=0 line for every x (x=0 y=0, x=1 y=0, x=2 y=0) instead of stopping after the first outer iteration.

  4. The consuming/borrowing example uses for n in &numbers to read without taking ownership. Write a loop that mutates each element in place using for n in &mut numbers and *n *= 10.

    Solution
    let mut nums = vec![1, 2, 3];
    for n in &mut nums {
        *n *= 10;
    }
    println!("{:?}", nums); // [10, 20, 30]

    &mut numbers yields &mut i32 items; *n dereferences to the actual i32 so *= 10 mutates the vec’s storage directly, no index needed.

  5. Rewrite the FizzBuzz function to use chained if/else if instead of match on a tuple. Which version reads more clearly, and why might the tuple-match version be preferred in Rust specifically (hint: think about what the compiler can check for you)?

    Solution

    An if/else if chain checking n % 15 == 0, then n % 3 == 0, then n % 5 == 0, then the fallback works identically — this is a style choice, not a correctness one. The tuple-match version is often preferred in Rust because match forces you to (exhaustively) consider every combination of (n % 3, n % 5) explicitly by name — (0, 0), (0, _), (_, 0), _ — making the four distinct cases visually explicit rather than implicit in a chain of conditions, and the compiler will refuse to compile if a future edit leaves a combination unhandled.

Frequently Asked Questions

Is if an expression in Rust?
Yes. if/else is an expression that returns a value, so you can use it in let bindings and function returns.
What is the difference between loop and while true?
loop is the idiomatic infinite loop. It can return a value via break. The compiler also knows loop never falls through, which helps type inference.
How do I loop over a range in Rust?
Use for i in 0..n for an exclusive range or 0..=n for inclusive. You can also call .rev() to iterate in reverse.