Controlling Execution Flow in Rust Programming Language: If-Else, Loops, and Pattern Matching Explained

Controlling Execution Flow in Rust

Control flow is usually the part of a language I skim past, because if-statements and loops are if-statements and loops, right? Rust made me slow down here too. The way Rust treats control flow as expressions rather than statements, combines it tightly with pattern matching, and forces exhaustiveness in a lot of places, genuinely changed how I structure logic. This article covers everything from the basics of if/else to the more advanced loop and pattern matching techniques I now use as default idioms.

If-Else as Expressions, Not Just Statements

In most languages, if is purely a control structure — it decides what runs, but it doesn’t produce a value. In Rust, if/else is an expression, which means it can return a value directly:

fn main() {
    let age = 20;

    let category = if age < 13 {
        "child"
    } else if age < 20 {
        "teenager"
    } else {
        "adult"
    };

    println!("Category: {category}");
}

This eliminates a whole pattern I used to write constantly in other languages — declaring a variable, then conditionally reassigning it in each branch. Every branch of the if/else must return the same type, and the compiler enforces this:

fn main() {
    let condition = true;
    // let result = if condition { 5 } else { "five" }; // compile error: mismatched types
    let result = if condition { 5 } else { 0 };
    println!("{result}");
}

Basic Conditionals

fn main() {
    let number = 7;

    if number % 4 == 0 {
        println!("divisible by 4");
    } else if number % 3 == 0 {
        println!("divisible by 3");
    } else if number % 2 == 0 {
        println!("divisible by 2");
    } else {
        println!("not divisible by 4, 3, or 2");
    }
}

One important detail: Rust never implicitly converts a value to bool the way C does with integers. The condition in an if must genuinely be of type bool, which removes an entire category of “truthy value” bugs.

Loops: three, and Rust makes all of them expressions too

Rust has three loop constructs: loop, while, and for. Each has a distinct purpose.

loop: Infinite by Default, But Can Return a Value

loop runs forever until you explicitly break out of it. Uniquely, break in Rust can carry a value, making loop itself an expression:

fn main() {
    let mut counter = 0;

    let result = loop {
        counter += 1;
        if counter == 10 {
            break counter * 2;
        }
    };

    println!("Result: {result}");
}

Output:

Result: 20

I use loop for retry logic, event loops, or any situation where the exit condition is more naturally expressed in the middle of the loop body rather than at the top.

Labeled Loops

When loops are nested, break and continue normally apply to the innermost loop. Labels let you target an outer loop explicitly:

fn main() {
    let mut count = 0;

    'outer: loop {
        let mut inner_count = 0;
        loop {
            if inner_count == 3 {
                break;
            }
            if count == 5 {
                break 'outer;
            }
            inner_count += 1;
            count += 1;
        }
    }

    println!("Final count: {count}");
}

This is one of those features I didn’t think I’d use often, but it comes up constantly in parsing and grid-based algorithms where you need to bail out of nested loops cleanly.

while: Conditional Looping

while loops run as long as a condition holds true, and are the direct equivalent of while loops in most other languages:

fn main() {
    let mut number = 5;

    while number != 0 {
        println!("{number}!");
        number -= 1;
    }

    println!("Liftoff!");
}

for: Iterating Over Collections and Ranges

for is what I reach for the vast majority of the time, because it’s both concise and safe — there’s no manual index management, so there’s no off-by-one risk.

fn main() {
    let numbers = [10, 20, 30, 40, 50];

    for number in numbers {
        println!("{number}");
    }

    for number in numbers.iter() {
        println!("Reference: {number}");
    }

    for (index, value) in numbers.iter().enumerate() {
        println!("Index {index}: {value}");
    }
}

Ranges make counting loops trivial:

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

    for i in 0..=5 {
        print!("{i} "); // 0 1 2 3 4 5 (inclusive)
    }
    println!();

    for i in (0..10).step_by(2) {
        print!("{i} "); // 0 2 4 6 8
    }
    println!();
}

Why for is Preferred Over Manual Indexing

Coming from C-style languages, my first instinct was to write:

fn main() {
    let numbers = [10, 20, 30];
    let mut i = 0;
    while i < numbers.len() {
        println!("{}", numbers[i]);
        i += 1;
    }
}

This compiles and works, but it’s not idiomatic, and it opens the door to off-by-one errors and unnecessary bounds checks on every access. The idiomatic for number in numbers.iter() version is not just cleaner — it also lets the compiler apply more aggressive optimizations, since the iterator’s bounds are statically known to be safe.

Pattern Matching as Control Flow

I covered match in depth in a companion article on enums, but it’s worth restating here: match is one of Rust’s primary control flow tools, not just an enum-handling feature. It works on integers, strings, tuples, structs, ranges, and more:

fn main() {
    let point = (0, 5);

    match point {
        (0, 0) => println!("origin"),
        (x, 0) => println!("on x-axis at {x}"),
        (0, y) => println!("on y-axis at {y}"),
        (x, y) => println!("at ({x}, {y})"),
    }
}

Because match is exhaustive, it acts as a built-in safeguard against forgetting a case — something plain if/else if chains never enforce.

if let and while let for Simpler Cases

When you only care about one pattern, if let avoids the ceremony of a full match:

fn main() {
    let config_value: Option<i32> = Some(3);

    if let Some(v) = config_value {
        println!("Config is {v}");
    }
}

let-else (stabilized in more recent Rust editions) is another pattern I’ve started using for early returns:

fn get_first_word(input: &str) -> &str {
    let Some(word) = input.split_whitespace().next() else {
        return "no words found";
    };
    word
}

This reads cleanly: either I successfully extract word and continue, or I return early. It avoids nesting an entire function body inside an if let block.

Iterators: Rust’s Preferred Way to Loop

Once I got comfortable with for, I started noticing how often loops are really just transforming or filtering data — and Rust’s iterator methods often express that more directly than a manual loop:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

    let sum: i32 = numbers.iter().sum();
    let even_squares: Vec<i32> = numbers.iter()
        .filter(|&&n| n % 2 == 0)
        .map(|&n| n * n)
        .collect();

    println!("Sum: {sum}");
    println!("Even squares: {:?}", even_squares);
}

Output:

Sum: 55
Even squares: [4, 16, 36, 64, 100]

This isn’t just style — iterator chains in Rust compile down to tight, often fully inlined machine code thanks to zero-cost abstractions, so there’s typically no performance penalty compared to a hand-written for loop with manual bounds checking.

Internal Working: How the Compiler Enforces Safety Here

A few control-flow-related guarantees are worth understanding at a slightly deeper level:

  • Exhaustiveness checking in match is done at compile time by analyzing every variant of the type being matched. This is why adding a new enum variant anywhere in your codebase immediately surfaces every match that needs updating.
  • Borrow checker interaction with loops: mutating a collection while iterating over it by reference is disallowed, because the iterator holds a reference into the collection, and Rust won’t let you invalidate that reference mid-loop.
fn main() {
    let mut v = vec![1, 2, 3];
    for x in &v {
        // v.push(4); // compile error: cannot borrow `v` as mutable
        println!("{x}");
    }
}

If I actually need to modify a collection while conceptually “looping” over it, I collect the changes into a new vector first, or use methods like retain() and drain() that are specifically designed to mutate safely during iteration.

Real-World Applications

  • Retry logic with loop and break value — retrying an HTTP request a set number of times and breaking out with the final response or error.
  • State machines with match — driving a parser or protocol handler based on the current state enum.
  • Data pipelines with iterators — filtering, transforming, and aggregating data from a file or database query without manual index management.
  • Early-exit validation with let-else — validating input at the top of a function and returning immediately on failure, keeping the “happy path” unindented.

Best Practices I Follow

  • Prefer for and iterator chains over manual while loops with an index variable — they’re safer and often clearer.
  • Use loop with break value when a computed result naturally comes from the loop itself, instead of a separate mutable variable declared before the loop.
  • Reach for match over long if/else if chains once you’re checking more than two or three conditions on the same variable, especially for enums.
  • Use labeled breaks for nested loops rather than boolean “should I stop” flags — it’s clearer and less error-prone.

Common Mistakes and Debugging Tips

Early on, I regularly hit the “cannot borrow as mutable because it’s also borrowed as immutable” error while trying to mutate a Vec inside a for loop over that same Vec. The fix is almost always to iterate over indices, or collect the needed updates separately and apply them after the loop finishes.

Another common issue is forgetting that if/else branches must return matching types when used as an expression. The compiler error here is usually clear, but if you’re new to Rust it can be confusing at first — the fix is to make sure every branch’s final expression has the same type, or explicitly return () from branches that only perform side effects.

If a match won’t compile because of “non-exhaustive patterns,” resist the urge to slap a catch-all _ => {} on it immediately — first check whether you actually need to handle the new case meaningfully, since that catch-all can hide real bugs later.

Frequently Asked Questions

Can for loops in Rust modify the collection they’re iterating over? Not directly by reference — you’d violate borrow checker rules. Use retain(), drain(), or collect changes into a new collection instead.

What’s the difference between break in a loop versus a for loop? Both exit the loop, but only loop supports returning a value with break valuefor and while loops always evaluate to ().

Is match slower than a series of if/else if statements? No — the compiler typically optimizes match into a jump table or efficient comparison chain, often making it as fast as or faster than a manual chain of conditionals.

When should I use while let instead of a for loop? Use while let when you’re repeatedly pulling values from something like a stack, queue, or iterator where the loop should stop once you get None, rather than iterating over a known, bounded collection.

Summary

Rust’s control flow constructs — if/else, loop, while, for, and match — are all expressions, tightly integrated with the type system and the borrow checker. That integration is what makes Rust’s control flow feel stricter than other languages at first, but it’s also what eliminates entire categories of bugs: unreachable code, forgotten cases, off-by-one errors, and mutation-during-iteration issues. Once these patterns became second nature to me, writing control flow in Rust stopped feeling like a constraint and started feeling like a guardrail that catches mistakes before they ship.

References

  • The Rust Programming Language Book — Chapter on Control Flow (doc.rust-lang.org/book)
  • The Rust Programming Language Book — Chapter on Iterators (doc.rust-lang.org/book)
  • Official Rust Standard Library documentation for iterators and control flow constructs (doc.rust-lang.org/std)
  • The Cargo Book (doc.rust-lang.org/cargo)
Total
0
Shares

Leave a Reply

Previous Post
Naming Objects in Rust

Naming Objects in Rust Programming Language: Variables, Constants, Shadowing, and Naming Conventions

Next Post
Using Data Sequences in Rust

Using Data Sequences in Rust Programming Language: Arrays, Vectors, and Tuples Complete Guide

Related Posts