The Three Shapes of Repetition
You're building a command-line tool that waits for a user to type "quit". Or a game engine that needs to render frames until the window closes. Or a parser chewing through a file byte by byte. In Python or JavaScript, you grab a while loop, maybe a for loop, and you're off. Rust gives you three distinct tools. They look similar, but they enforce different guarantees. Picking the wrong one leads to compiler errors or subtle bugs. Picking the right one makes your intent scream at the reader.
Rust treats loops as control flow with strict boundaries. loop runs forever until you explicitly break. while checks a boolean condition before each iteration. for consumes an iterator, handling the index or element progression automatically. The key difference isn't just syntax. It's about who manages the state. With loop and while, you manage the counter or the flag. With for, the iterator manages the progression, and you just react to the values.
Minimal Examples
Here are the three constructs in their simplest forms. Notice how for requires no manual counter and no condition check. The iterator drives the loop.
/// Demonstrates the three loop constructs and their exit strategies.
fn main() {
// loop: runs forever until break.
// Use this when the exit condition is deep inside the body.
loop {
println!("Spinning...");
break; // Pull the plug.
}
// while: checks condition before each turn.
// You manage the variable that changes.
let mut count = 0;
while count < 3 {
println!("Count: {count}");
count += 1; // Manual increment.
}
// for: drives itself via an iterator.
// No manual counter. No off-by-one errors.
for number in 1..=3 {
println!("Number: {number}");
}
}
Loops as Expressions
Rust is an expression-oriented language. Almost every block of code produces a value. Loops are no exception. A loop block can return a value. When you break with a value, that value becomes the result of the loop. This eliminates the "result variable" pattern common in other languages. Instead of declaring a mutable variable outside the loop and assigning to it, you declare the variable inside the break. This keeps mutation localized and makes the code harder to mess up.
/// Finds the first even number in a sequence starting from `start`.
fn find_first_even(start: u32) -> u32 {
// The loop itself is an expression that returns a u32.
// No mutable variable needed outside.
loop {
if start % 2 == 0 {
break start; // The loop evaluates to this value.
}
start += 1;
}
}
If you use break with a value, the type must match the loop's expected type. If the loop is inside a function returning Result, and you break with a String, the compiler rejects you with E0308 (mismatched types). The compiler checks the type of every break expression in a loop and ensures they all unify. This catches logic errors where different exit paths return different types.
Convention aside: prefer break with a value over a mutable result variable. The community calls this "returning from the loop." It signals that the loop's purpose is to compute that value. If you hoist a mutable variable outside, readers have to scan the whole loop body to find where it gets assigned. break puts the result right where the loop ends.
The for Loop Secret
Under the hood, Rust desugars for into a loop that calls .next() on an iterator. This means for is the most flexible construct, even though it looks the simplest. You can implement for loops for your own types by implementing the Iterator trait. Any type that implements Iterator can be used in a for loop. This includes ranges, vectors, strings, and custom data structures.
The desugaring looks roughly like this:
// This:
for item in collection {
// body
}
// Desugars to something like:
let mut iter = IntoIterator::into_iter(collection);
loop {
match iter.next() {
Some(item) => {
// body
}
None => break,
}
}
This reveals why for is safer than while with an index. The iterator handles the bounds. You can't accidentally access an element past the end. The compiler knows the iterator's lifetime and enforces borrowing rules. If you try to mutate the collection while iterating over it, the borrow checker stops you. This prevents use-after-free bugs and data races at compile time.
Ranges in Rust are half-open by default. 0..3 produces 0, 1, 2. It excludes the upper bound. This matches array indexing and slice semantics. If you want to include the upper bound, use ..=. 0..=3 produces 0, 1, 2, 3. The inclusive range is explicit. The compiler warns you if you mix up the two.
Nested Loops and Labels
Rust supports labeled loops. You can attach a label to a loop and break or continue to that label. This is useful for nested loops where you need to break out of the outer loop from inside the inner loop. In many languages, you need a flag variable or a function return to achieve this. Rust makes it direct.
/// Searches for a target in a 2D grid.
fn find_in_grid(grid: &[&[u32]], target: u32) -> Option<(usize, usize)> {
'outer: for (row_idx, row) in grid.iter().enumerate() {
for (col_idx, &value) in row.iter().enumerate() {
if value == target {
break 'outer Some((row_idx, col_idx));
}
}
}
None
}
The label 'outer marks the outer loop. break 'outer breaks out of that specific loop. You can also continue 'outer to skip to the next iteration of the outer loop. Labels work with loop, while, and for. They can also have values. break 'label value returns a value from the labeled loop. This is powerful for complex control flow.
Convention aside: use labels sparingly. Deeply nested loops with labels can be hard to read. If you find yourself needing multiple labels, consider refactoring into functions or iterators. Labels are a tool, not a style. Use them when they clarify intent, not when they obscure it.
Realistic Example: Retry Logic
A common pattern in systems code is retrying an operation on failure. loop shines here because the exit condition depends on a result and a counter. The logic is buried in the body. while would require awkward condition checks. for doesn't fit because the number of iterations isn't known in advance.
/// Retries an operation up to a limit, returning the result or an error.
fn retry_operation(max_retries: u32) -> Result<String, &'static str> {
let mut attempts = 0;
// loop is ideal here because the exit condition involves
// checking a result and a counter, not just a boolean.
loop {
attempts += 1;
println!("Attempt {attempts}...");
// Simulate work that might fail.
let result = if attempts == max_retries {
Ok("Success".to_string())
} else {
Err("Transient error")
};
match result {
Ok(value) => break Ok(value), // Break with a value.
Err(e) if attempts < max_retries => {
println!("Failed: {e}. Retrying...");
continue; // Skip to next iteration.
}
Err(e) => break Err(e), // Exhausted retries.
}
}
}
This code uses break with a value to return the result. It uses continue to skip to the next iteration. The loop block returns a Result. The compiler ensures all break paths return the same type. If you forgot to handle the error case, the compiler would complain about missing break or mismatched types.
Pitfalls and Errors
Infinite loops happen. If you write a loop without a break, the program runs forever. The compiler won't catch this unless the loop is obviously infinite. Use loop only when you have a clear exit strategy. If the exit condition is simple, use while instead.
Off-by-one errors plague index-based loops. for eliminates this risk. If you find yourself writing let mut i = 0; while i < n { ... i += 1 }, reach for for i in 0..n instead. The compiler can optimize for loops better, and you eliminate manual counter management.
Mutable state in while loops can lead to bugs. If you forget to update the condition variable, you get an infinite loop. If you update it incorrectly, you get wrong results. for loops avoid this by design. The iterator manages the state.
E0308 appears when break values don't match. If a loop has multiple break points, they must all return the same type. The compiler unifies the types. If they conflict, you get E0308. Fix this by ensuring all exit paths return compatible types.
E0382 (use of moved value) can occur if you move a value into a loop and try to use it after the loop. Loops consume iterators. If you iterate over a vector with for item in vec, the vector is moved. You can't use vec after the loop. Use a reference for item in &vec if you need to keep the vector.
Decision Matrix
Use loop when the exit condition is buried deep in the body or depends on a value computed inside the loop. Use loop when you need to return a value from the loop itself via break. Use while when the repetition depends on a simple boolean condition checked before each iteration. Use for when you're iterating over a collection, range, or any type that implements Iterator. Use while let when you're draining a collection or unwrapping an Option/Result until it becomes None or Err.
while let is a pattern matching loop. It runs while the pattern matches. It's syntactic sugar for a loop with a match. It's idiomatic for draining iterators or processing Option chains.
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
println!("Popped: {top}");
}
This loop runs until stack.pop() returns None. The while let syntax makes the intent clear. You're processing items until the stack is empty.