The pipeline that never turns on
You write a chain of iterator adapters. You filter a list of numbers, map them to strings, take the first five, and assign the whole thing to a variable. You run the program. Nothing happens. No output. No errors. The process exits cleanly. You stare at the terminal, wondering if Rust just swallowed your code.
It did not. Your code is waiting. Rust iterators are lazy by design. They build a blueprint of operations instead of executing them immediately. The work only starts when you explicitly ask for a result.
Lazy versus eager in plain terms
Eager evaluation runs code the moment you define it. You give it a list, it processes every item, stores the results, and hands you a new list. Lazy evaluation builds a pipeline. It records what you want to do and waits for a consumer to pull items through it.
Think of a factory assembly line. Eager evaluation is like baking every cookie in a batch, piling them on a table, sorting the pile, then packaging the sorted pile. You need floor space for the full batch at every stage. Lazy evaluation is a conveyor belt. The oven bakes one cookie. It slides onto the belt. The sorter grabs it, checks it, and passes it to the packager. The packager boxes it. Only then does the belt move forward to bake the next cookie. No intermediate piles. No wasted memory. The line only moves when the packager is ready for the next item.
Rust uses the conveyor belt model for iterators. Every adapter like filter, map, take, or enumerate just wraps the previous iterator and adds a step to the chain. The chain sits idle until a consumer method pulls it.
A minimal example
/// Demonstrates lazy evaluation with a simple map chain.
fn main() {
// Create the source collection on the stack.
let numbers = vec![1, 2, 3];
// Building the chain does not run the closure.
// It only creates an iterator adapter that knows how to double values.
// The closure captures `x` by reference and returns a new integer.
let doubled = numbers.iter().map(|x| {
println!("Processing {}", x);
x * 2
});
// This prints immediately because it is outside the iterator chain.
println!("Chain built, but nothing ran yet.");
// The consumer forces the chain to execute.
// Each item flows through the map closure exactly once.
// collect() allocates a new Vec and pushes each yielded value into it.
let result: Vec<_> = doubled.collect();
println!("Final result: {:?}", result);
}
Run this and you will see the "Chain built" message first. The println! inside the closure stays silent. Only when collect() runs does the closure fire three times. The output appears in order, followed by the final vector.
How the chain actually executes
Lazy iterators work on a pull model. The consumer calls Iterator::next() on the outermost adapter. That adapter calls next() on the adapter before it. This continues until the call reaches the original source, like a Vec or a file reader. The source yields one item. The item travels back up the chain. Each adapter transforms or filters it exactly once. The final result reaches the consumer. The consumer calls next() again. The cycle repeats until the source returns None.
This design gives Rust two major advantages. First, it avoids intermediate allocations. If you chain filter().map().filter().collect(), eager evaluation would create a new vector after every step. Lazy evaluation creates exactly one vector at the end. Second, it enables short-circuiting. Methods like take(5) or find() stop the chain early. The source never yields more items than the consumer actually needs.
The compiler optimizes this heavily. Because the chain is just a series of structs implementing the Iterator trait, the compiler inlines the entire pipeline. At runtime, it looks like a single tight loop. You get the readability of chained methods with the performance of a hand-written for loop. The trait bounds resolve at compile time, so there is zero dynamic dispatch overhead.
Convention note: developers usually chain adapters on separate lines for readability. The compiler does not care about line breaks, but humans do. Align the methods vertically so the data flow reads top to bottom. This makes it trivial to spot where a transformation breaks or where a type mismatch occurs.
Realistic example: processing log lines
Real code rarely doubles numbers. It usually parses text, filters noise, and aggregates data. Here is how lazy evaluation handles a typical log processing task.
/// Parses log lines and counts errors per module.
fn count_errors(lines: &[&str]) -> usize {
// The chain defines the transformation pipeline.
// No allocation happens until count() runs.
// Each adapter wraps the previous one in a zero-cost struct.
lines.iter()
.filter(|line| line.starts_with("[ERROR]"))
.map(|line| line.split_whitespace().nth(1))
.filter_map(|module| module)
.take(100)
.count()
}
The filter drops non-error lines. The map extracts the module name. The filter_map handles cases where the split fails and returns None. The take(100) caps the count. The count() consumer pulls items until it hits one hundred or the slice ends.
If you wrote this eagerly, you would allocate a vector for filtered lines, then another for mapped strings, then another for the final count. The lazy chain touches each line once, keeps only a single string slice in memory at a time, and stops early if you hit the limit. Memory usage stays flat regardless of input size.
Pitfalls and compiler hints
Lazy evaluation introduces a few traps that catch beginners. The most common is forgetting the consumer. You write a chain, assign it to a variable, and expect side effects to run. They do not. The compiler will warn you about an unused variable if you do not suppress it with let _ = chain;. The warning does not tell you the chain is lazy. It just says you never used the value.
Side effects inside lazy chains behave unpredictably if you consume the iterator twice. Standard iterators are single-use. Calling next() or collect() exhausts them. If you need to run the same chain twice, you must clone the source or recreate the chain. The compiler will reject a second use with E0382 (use of moved value) if you try to consume it again without cloning.
Another trap is assuming for_each is an adapter. It is a consumer. Calling for_each on a chain executes it immediately and returns (). You cannot chain more adapters after it. If you accidentally write .for_each(|x| x * 2).filter(...), the compiler rejects it with E0599 (no method named filter found for type ()). The fix is to put for_each at the very end, or switch to collect if you need the results later.
Debug lazy chains by inserting inspect adapters. inspect takes a closure, runs it on every item, and passes the item through unchanged. It is the standard way to peek at pipeline state without breaking the chain. Place it right before the step that misbehaves. You will see exactly what data enters and leaves that adapter.
Treat the iterator chain as a single logical unit. If the types do not line up, the compiler will catch it at the point where the mismatch occurs, not at the end of the chain.
When to use lazy chains versus eager loops
Use lazy iterator chains when you are transforming or filtering collections and want to avoid intermediate allocations. Use lazy chains when you need short-circuiting behavior like take, find, or any. Use lazy chains when the logic reads better as a declarative pipeline than an imperative loop.
Use eager for loops when you need complex control flow that iterators struggle with, like breaking out of nested loops or mutating multiple collections in sync. Use eager loops when profiling shows the iterator overhead is measurable in your hot path, though this is rare. Use eager loops when you are writing tight numerical kernels where explicit indexing gives you more control over cache lines and vectorization.
Use collect() when you need to materialize the results into a concrete type like Vec, HashMap, or String. Use for_each() when you only care about side effects like printing or writing to a file. Use count(), sum(), or product() when you need a single aggregated value.
Trust the pipeline. If you can read the chain from top to bottom and understand the transformation, let Rust handle the execution.