Control Flow

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Rust 1.99.0 and the 2024 edition, checked on 2026-10-06. Rust’s control-flow constructs are expressions: if, match and loop all produce values, which removes the need for a ternary operator and for many temporary mut variables. Pattern matching with match is covered in depth in Enums & Pattern Matching; this page covers everything else, plus the pattern-based shorthands if let, let-else, while let and let chains.

if expressions

The condition must be a bool — there is no implicit conversion from integers, Options or strings, and no parentheses are needed around it. The braces are mandatory.

fn main() {
    let copies = 2;
    if copies == 0 {
        println!("unavailable");
    } else if copies < 3 {
        println!("few copies left");
    } else {
        println!("available");
    }
}

Because if is an expression, it can produce a value. Both branches must then have the same type, and an if used as a value needs an else:

fn main() {
    let copies = 2;
    let status = if copies == 0 { "unavailable" } else { "available" };
    let fee_cents: u32 = if copies > 0 { 0 } else { 150 };
    println!("{status} {fee_cents}");
}

Mismatched branch types are error E0308 (the message says the if and else branches have incompatible types); an if without else used as a non-unit value is error E0317. The common coercions still apply: a branch that diverges (return, panic!, continue) has type ! and fits any other branch.

fn main() {
    let copies = 2;
    let label = if copies > 0 { "available" } else { 0 }; // error[E0308]: `if` and `else` have incompatible types
    println!("{label}");
}

Repetition with loop, while and for

loop and break with a value

loop repeats forever until break. Because loop is an expression, break can carry a value out of it — the idiomatic way to retry an operation until it succeeds:

fn main() {
    let attempts = ["x", "abc", "42"];
    let mut i = 0;
    let year: u32 = loop {
        match attempts[i].parse() {
            Ok(n) => break n,    // the loop evaluates to n
            Err(_) => i += 1,
        }
    };
    println!("parsed {year} after {i} failures"); // parsed 42 after 2 failures
}

A loop without a reachable break has type !, so fn serve() -> ! { loop { ... } } is a valid server main loop.

Loop labels

break and continue apply to the innermost loop. Label an outer loop with a lifetime-like name ('outer:) to target it. Labels also work on plain blocks, so break 'label value can exit a block early (1.65):

fn main() {
    let shelves = [["Dune", "Emma"], ["Ulysses", "Beloved"], ["Kindred", "Middlemarch"]];
    let target = "Beloved";

    let mut found = None;
    'search: for (s, shelf) in shelves.iter().enumerate() {
        for (p, title) in shelf.iter().enumerate() {
            if *title == target {
                found = Some((s, p));
                break 'search; // leave both loops
            }
        }
    }
    println!("{found:?}"); // Some((1, 1))

    let discount = 'calc: {
        if target.len() < 5 {
            break 'calc 0; // labelled block: early exit with a value
        }
        10
    };
    println!("{discount}"); // 10
}

while

while condition { ... } loops as long as the condition is true. It always evaluates to (), so break inside while cannot carry a value. Use it when the stopping condition is not "end of a sequence":

fn main() {
    let mut balance_cents: i32 = 450;
    let mut days = 0;
    while balance_cents > 0 {
        balance_cents -= 100;
        days += 1;
    }
    println!("cleared after {days} days");
}

for loops over ranges and iterators

for pattern in expression iterates over anything that implements IntoIterator: ranges, arrays, collections, iterators. It is the safest and usually the fastest loop — there is no index to get wrong and bounds checks are elided.

fn main() {
    for day in 1..=3 {                    // inclusive range: 1, 2, 3
        print!("day {day} ");
    }
    println!();
    for n in (0..10).rev().step_by(3) {   // 9, 6, 3, 0
        print!("{n} ");
    }
    println!();

    let titles = vec![String::from("Dune"), String::from("Emma")];
    for (i, title) in titles.iter().enumerate() {   // borrow each element
        println!("{i}: {title}");
    }
    for title in &titles {                          // same as titles.iter()
        print!("{} ", title.len());
    }
    println!();
    for title in titles {                           // consumes the Vec: titles is moved
        drop(title);
    }
    // println!("{titles:?}"); // would not compile: titles was moved by the for loop (E0382)
}
Loop over Element type The collection afterwards

for x in &v or v.iter()

&T

still usable

for x in &mut v or v.iter_mut()

&mut T

still usable, possibly modified

for x in v or v.into_iter()

T

moved (consumed)

The IntoIterator rules and iterator adapters are covered in Iterators. Ranges are written a..b (exclusive end), a..=b (inclusive end) and a.. (unbounded); ..b and .. are used for slicing. Rust 1.95 and 1.96 stabilised the core::range module, which provides new Copy-able range types intended to replace the current ones in a future edition.

if let: matching one pattern

When you only care about one pattern, if let is shorter than a match with a _ => {} arm:

fn main() {
    let rating: Option<u8> = Some(4);
    if let Some(stars) = rating {
        println!("{stars} stars");
    } else {
        println!("not rated");
    }

    // The match it replaces:
    match rating {
        Some(stars) => println!("{stars} stars"),
        None => println!("not rated"),
    }
}

if let gives up exhaustiveness checking, so prefer match when every variant needs handling.

let-else: bind or bail out

let PATTERN = EXPR else { DIVERGE }; binds the pattern’s variables in the enclosing scope if the pattern matches, and otherwise runs the else block, which must diverge (return, break, continue, panic!). It is the idiomatic way to write guard clauses without nesting (stable since 1.65):

fn parse_loan(line: &str) -> Result<(String, u16), String> {
    let Some((member, days)) = line.split_once(',') else {
        return Err(format!("missing comma in {line:?}"));
    };
    let Ok(days) = days.trim().parse::<u16>() else {
        return Err(format!("bad day count in {line:?}"));
    };
    Ok((member.trim().to_owned(), days))
}

fn main() {
    println!("{:?}", parse_loan("alice, 21"));   // Ok(("alice", 21))
    println!("{:?}", parse_loan("bob"));         // Err("missing comma in \"bob\"")
}

The else block that falls through is error E0308 (the message says the else clause of let...else does not diverge). An irrefutable pattern (one that always matches) with else triggers the irrefutable_let_patterns warning.

while let

while let loops as long as a pattern matches — typically draining a stack or pulling from a channel or iterator:

fn main() {
    let mut returns = vec!["Dune", "Emma", "Ulysses"];
    while let Some(title) = returns.pop() {
        println!("reshelving {title}");
    }
}

Let chains (1.88, edition 2024)

Let chains allow if let and while let conditions to be combined with each other and with ordinary boolean expressions using &&. Bindings from earlier conditions are available in later ones. This removes the "rightward drift" of nested if let blocks. Let chains were stabilised in Rust 1.88 and are available only in crates on the 2024 edition, because the edition changed the drop order of if let temporaries that let chains depend on.

use std::collections::HashMap;

fn due_soon(loans: &HashMap<&str, u16>, member: &str) -> bool {
    if let Some(&days) = loans.get(member)
        && days <= 3
        && let Some(initial) = member.chars().next()
        && initial.is_ascii_alphabetic()
    {
        println!("{member}: due in {days} days");
        true
    } else {
        false
    }
}

fn main() {
    let loans = HashMap::from([("alice", 2), ("bob", 14)]);
    assert!(due_soon(&loans, "alice"));
    assert!(!due_soon(&loans, "bob"));
}

|| is not allowed between let conditions, because a binding would not be guaranteed to exist in the body. In a 2021-edition crate, the same code is rejected with "let chains are only allowed in Rust 2024 or later".

if let guards in match arms (1.95)

A match guard is an extra if condition on a match arm. Since Rust 1.95, a guard can itself be an if let, binding variables that the arm body can use; previously you needed a nested match or a let chain in the body:

enum Request {
    Lend { isbn: String },
    Search(String),
}

fn handle(req: &Request) -> String {
    match req {
        Request::Lend { isbn } if let Some(prefix) = isbn.strip_prefix("978") => {
            format!("lend book with registration group {}", &prefix[..1])
        }
        Request::Lend { isbn } => format!("unsupported ISBN {isbn}"),
        Request::Search(q) if q.is_empty() => "empty query".to_string(),
        Request::Search(q) => format!("search for {q}"),
    }
}

fn main() {
    println!("{}", handle(&Request::Lend { isbn: "9780441013593".into() }));
    println!("{}", handle(&Request::Lend { isbn: "123".into() }));
    println!("{}", handle(&Request::Search(String::new())));
}

If the if let guard does not match, matching continues with the next arm, exactly like a boolean guard that evaluates to false. Guards are not considered by exhaustiveness checking, so a final unguarded arm is still needed.

The 2024 edition’s temporary-scope changes

Temporaries — values created while evaluating an expression, such as the MutexGuard returned by lock() or the Ref returned by RefCell::borrow() — are dropped at the end of their temporary scope. The 2024 edition shortened two of those scopes, which fixes a class of surprising deadlocks and borrow errors.

if let scrutinee temporaries (if_let_rescope): in 2021, temporaries in the if let condition lived until the end of the whole if let ... else statement, including the else block. In 2024 they are dropped before the else block runs:

use std::sync::Mutex;

fn take_or_restock(stock: &Mutex<Vec<&'static str>>) -> &'static str {
    if let Some(title) = stock.lock().unwrap().pop() {
        title
    } else {
        // Edition 2024: the guard from `stock.lock()` above has already been dropped here,
        // so locking again is fine. In edition 2021 this second lock() deadlocks.
        stock.lock().unwrap().push("Dune");
        "restocked"
    }
}

fn main() {
    let stock = Mutex::new(vec![]);
    println!("{}", take_or_restock(&stock)); // restocked
    println!("{}", take_or_restock(&stock)); // Dune
}

Tail-expression temporaries (tail_expr_drop_order): in 2021, temporaries in a block’s tail expression were dropped after the block’s local variables, which made code like the following fail to compile with "borrowed value does not live long enough" (E0597). In 2024 they are dropped before the locals:

use std::cell::RefCell;

fn count() -> usize {
    let shelf = RefCell::new(vec!["Dune", "Emma"]);
    shelf.borrow().len() // 2024: the `Ref` temporary is dropped before `shelf`; 2021: E0597
}

fn main() {
    println!("{}", count());
}

cargo fix --edition rewrites code whose behaviour would change — for example, it turns an if let whose guard must live through the else block into a match. The rules for temporary scopes in general are in the Reference’s "Destructors" chapter and on Drop, RAII & Drop Check.

Choosing a construct

You want to …​ Use

choose between two values

if c { a } else { b }

handle every variant of an enum

match

act on one pattern and ignore the rest

if let

bind a value or return early

let-else

combine patterns and boolean tests

let chains (2024)

refine a match arm with a pattern

if let guard (1.95)

retry until success and keep the result

loop + break value

iterate a collection or range

for

consume a stack, queue or channel

while let

Where the books are outdated

None of the consulted books covers let chains (1.88), if let guards (1.95) or the 2024 edition’s temporary-scope changes, and the older editions they target (2018 and 2021) reject let chains outright. Code from them that holds a lock in an if let condition and locks again in the else branch deadlocks on 2021 but works on 2024.