The loop that stops itself
You are polling a network socket for incoming messages. You are reading tokens from a parser until the input runs out. You are draining a queue of tasks. In Python or JavaScript, you write a while True loop and check a condition inside, or you use a for loop over an iterator. In Rust, you reach for loop and match, writing a block that checks the value, breaks on failure, and processes the success case. It works, but it carries boilerplate. You have to write the break, you have to handle the non-matching case, and the intent gets buried in control flow.
Rust provides while let to cut that noise. It combines a loop and a pattern match into a single construct. The loop runs as long as the expression matches the pattern. When the expression stops matching, the loop ends automatically. You don't write the break. You don't write the fallback case. The pattern match drives the control flow.
The doorknob analogy
Imagine a hallway of doors. You want to walk through every door that is unlocked. You stand at the first door and try the handle. If the door opens, you walk through and move to the next door. You repeat this process. When you finally reach a door that is locked, you stop. You don't need to check "is this door locked?" separately. The act of trying the handle tells you everything. If it opens, you proceed. If it doesn't, you're done.
while let is the doorknob. The expression is the attempt to open the door. The pattern is the unlocked state. The loop body is walking through. When the pattern fails to match, the loop stops.
Minimal example
The most common use case is consuming an Option-returning method. Vectors, deques, and channels often provide methods that return Some(value) while data exists and None when empty.
use std::collections::VecDeque;
fn drain_queue(mut queue: VecDeque<String>) {
// pop_front returns Option<String>.
// while let matches Some(item) and extracts the String.
// The loop continues until pop_front returns None.
while let Some(item) = queue.pop_front() {
println!("Processing: {item}");
}
}
fn main() {
let mut q = VecDeque::new();
q.push_back("task_a".to_string());
q.push_back("task_b".to_string());
drain_queue(q);
}
The loop processes "task_a", then "task_b". On the third iteration, pop_front returns None. The pattern Some(item) does not match None. The loop terminates. No manual break required.
How the compiler sees it
while let is syntactic sugar. The compiler desugars it into a loop with a match and a break. Understanding this desugaring explains every behavior of the construct.
The code above transforms into:
loop {
match queue.pop_front() {
Some(item) => {
println!("Processing: {item}");
}
_ => break,
}
}
The expression queue.pop_front() evaluates on every iteration. The result matches against the pattern. If it matches, the body runs. If it doesn't, the wildcard arm breaks the loop. This reveals two critical facts.
First, the expression must change state between iterations. If the expression always returns the same value, you get an infinite loop. Second, the pattern can be non-exhaustive. The compiler fills in the _ => break arm for you. You don't need to handle every variant of the enum.
Ownership and moving values
Patterns in while let can move values out of the expression. This is powerful for consuming resources efficiently. When you bind a variable in the pattern, you can take ownership of the value.
fn consume_strings(mut list: Vec<String>) {
// pop() returns Option<String>.
// The pattern Some(s) moves the String out of the Option.
// Each iteration takes ownership of one String.
// No cloning occurs.
while let Some(s) = list.pop() {
process_owned_string(s);
}
}
fn process_owned_string(s: String) {
println!("Got {s}");
}
The variable s is a fresh binding in each iteration. It owns the value extracted from the Option. When the loop body ends, s drops. The next iteration binds a new s. This allows you to drain collections without copying data. The compiler tracks the moves precisely. If you try to use list inside the loop body after moving a value out, you might hit E0382 (use of moved value) depending on how the method borrows. Methods like pop take &mut self, so they borrow the vector temporarily to extract the value. The borrow ends before the body runs, so you can usually call other methods on list inside the body, provided they don't conflict with the moved value.
Async streams
Async Rust relies heavily on while let for consuming streams. Channels, network streams, and event loops often provide async methods that return Option. The await keyword sits inside the expression.
use tokio::sync::mpsc;
async fn listen_for_messages(mut rx: mpsc::Receiver<String>) {
// recv() returns Option<String>.
// The loop awaits each message.
// When the sender drops, recv() returns None.
// The loop stops automatically.
while let Some(msg) = rx.recv().await {
println!("Received: {msg}");
}
println!("Channel closed.");
}
This is the idiomatic way to consume async streams. The loop suspends on await when no message is available. It resumes when a message arrives. When the channel closes, recv returns None, the pattern fails, and the loop exits. You don't need to poll manually. You don't need to check for closure. The pattern match handles the lifecycle.
Handling Results
while let works with Result just as well as Option. The pattern matches Ok(value), and the loop stops when the expression returns Err. This creates a "consume until failure" pattern.
fn read_until_error(mut reader: LineReader) {
// read_line returns Result<String, Error>.
// The loop processes lines as long as reads succeed.
// On the first error, the loop stops.
while let Ok(line) = reader.read_line() {
println!("Line: {line}");
}
// Execution continues here after the error.
println!("Stopped due to error or EOF.");
}
This pattern assumes you want to stop on the first error. If you need to handle errors and continue, while let is the wrong tool. You need a loop with match to handle the Err case explicitly. while let discards the non-matching case by breaking. You lose the error value. If you need to log the error or retry, use loop.
Pitfalls and traps
The infinite loop is the most common mistake. The expression must change state. If you write while let Some(x) = Some(1), the loop runs forever. The expression Some(1) always matches Some(x). The compiler cannot always detect this, especially if the expression involves function calls.
// This loops forever.
// The expression returns Some(1) every time.
// The pattern always matches.
// The loop never breaks.
while let Some(x) = get_constant_value() {
println!("{x}");
}
Check your expression. Ensure it consumes state, advances an iterator, or polls a resource that can become empty. If the expression is a method call, verify the method modifies the receiver or external state.
Shadowing can cause confusion. Variables bound in the pattern are local to the loop body. They don't leak out. If you need the value after the loop, you must store it elsewhere.
let mut last_value = None;
while let Some(val) = source.next() {
last_value = Some(val);
}
// val is not accessible here.
// last_value holds the final result.
if let Some(final_val) = last_value {
println!("Last: {final_val}");
}
Type mismatches trigger E0308. If the expression returns a type that doesn't match the pattern, the compiler rejects the code.
// Error: E0308 mismatched types.
// The expression returns Option<i32>.
// The pattern expects Option<String>.
while let Some(s) = get_number() {
println!("{s}");
}
The compiler tells you the types don't align. Fix the pattern or the expression.
Decision matrix
Choose the right loop construct based on your data source and control needs.
Use for when you have an iterator and want to visit every item. for is the standard for collections, ranges, and any type implementing IntoIterator. It is concise and idiomatic.
Use while let when you have a stateful expression that returns Option or Result and you want to loop until it returns None or Err. Use while let for draining queues, consuming async streams, or polling resources until they exhaust.
Use loop with match when you need to handle the failure case inside the loop, or when you have multiple break conditions. Use loop when you need to retry on error, skip bad values, or break based on complex logic that doesn't fit a single pattern match.
Use while let when the stop condition is the absence of data. Use loop when the stop condition is logic.
Convention and style
The Rust community treats while let as the idiomatic way to consume Option-returning methods. If you see a loop that matches Some and breaks on None, refactor it to while let. It reduces nesting and clarifies intent.
When using while let with Result, the convention signals "stop on error". If you need to handle errors, switch to loop. Don't use while let Ok(x) = ... if you intend to recover from errors. The pattern implies termination on failure.
Keep the expression simple. Avoid complex logic inside the while let condition. If the expression requires multiple steps, extract it into a helper function or use a loop with clearer structure.
Don't use while let to iterate over a collection. Use for. while let Some(x) = vec.pop() works, but for x in vec is faster to write, clearer to read, and avoids mutating the vector. Reserve while let for cases where for doesn't apply.