Lazy Evaluation in Iterators
In Rust, calling .map(), .filter(), or .take() on an iterator does not do any work. It just builds a small object that describes a step in a pipeline. Nothing actually runs until you hand that pipeline to something that consumes it, like .collect() or a for loop. This property is called lazy evaluation, and it is one of the reasons Rust iterators are both extremely fast and able to work with sequences that are unbounded, expensive, or even infinite.
Overview: What “Lazy” Really Means
Every iterator in Rust is built on a single trait method: fn next(&mut self) -> Option<Self::Item>. Everything else — map, filter, take, zip, enumerate, and dozens of others — is implemented on top of that one method. When you write numbers.iter().map(f), you are not asking Rust to go apply f to every element right now. You are constructing a value (a struct, internally something like Map { iter: numbers.iter(), f }) that remembers how to produce the next transformed element if and when someone asks for it by calling next(). No memory is allocated for a result, and the closure f is not called even once, until a consumer drives the chain.
Think of it like a recipe versus a finished meal. v.iter().map(f).filter(g) is a recipe: “for each element that comes out of v.iter(), apply f, then keep it only if g approves.” Nothing has been cooked yet. Only when you say .collect() (or loop over it, or call .sum()) does Rust actually start pulling elements through the recipe, one at a time, from the very last stage back to the first. This is called a pull-based model: the consumer at the end of the chain pulls one element by calling next(), which calls next() on the stage before it, and so on back to the original data source.
Contrast this with languages where building a list transformation eagerly produces a whole new list at every step. If you chained five .map() calls over a million-element list eagerly, you would allocate five million-element intermediate lists. In Rust, that same five-stage chain compiles down to a single loop that touches each element once, with no intermediate allocations at all — this is what people mean when they call Rust iterators a “zero-cost abstraction.” Laziness is also what makes infinite sequences usable: a range like 1.. (with no upper bound) or std::iter::repeat(0) can never be turned into a list, but you can absolutely ask a lazy pipeline built on top of it for “the first even number greater than 100,” because the pipeline only pulls as many elements as it needs before stopping.
Adapters vs. Consumers
It helps to sort iterator methods into two groups. Adapters (map, filter, take, skip, zip, enumerate, chain, flat_map, inspect, …) are lazy: each one wraps the previous iterator and returns a new iterator, doing no work by itself. Consumers (collect, sum, count, for_each, fold, find, any, all, and the humble for loop) are what actually call next() repeatedly and make the pipeline run. Until you reach a consumer, you can chain as many adapters as you like and nothing happens.
Syntax
There is no special syntax for laziness — it falls directly out of how the Iterator trait is designed. The general shape of a lazy pipeline looks like this:
iterable
.iter() // or .into_iter(), .iter_mut()
.adapter_1(...) // e.g. .map(...), .filter(...), .take(...)
.adapter_2(...)
.consumer(...) // e.g. .collect(), .sum(), .for_each(...), for loop
| Part | Meaning |
|---|---|
.iter() / .into_iter() / .iter_mut() |
Creates the base iterator (borrowed items, owned items, or mutable references, respectively). This is where element production ultimately originates. |
adapter_N(...) |
A lazy method that wraps the previous iterator. Returns a new iterator type; performs no work when called. |
consumer(...) |
A method (or a for loop) that repeatedly calls next() until it decides to stop. This is the only thing that actually executes the pipeline. |
Examples
The following three examples build up from “proving laziness exists” to using it deliberately for efficiency.
Example 1: Nothing Runs Until You Collect
This example inserts a println! inside a map closure so we can literally see when the transformation runs.
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
let mapped = numbers.iter().map(|n| {
println!("processing {}", n);
n * 2
});
println!("iterator created, nothing has run yet");
let doubled: Vec<i32> = mapped.collect();
println!("{:?}", doubled);
}
Output:
iterator created, nothing has run yet
processing 1
processing 2
processing 3
processing 4
processing 5
[2, 4, 6, 8, 10]
Notice the order: the “nothing has run yet” message prints before any “processing” message, even though the map call appears earlier in the source. Building the Map iterator did nothing. Only .collect(), the consumer, actually walked the elements and invoked the closure five times.
Example 2: Short-Circuiting an Infinite Iterator
1.. is a RangeFrom with no upper bound — an infinite iterator. Trying to eagerly turn it into a list would never finish. But .find() is a consumer that stops as soon as it has an answer, so it works fine with an infinite source.
fn main() {
let result = (1..).map(|n| {
println!("checking {}", n);
n
}).find(|n| n % 2 == 0 && *n > 10);
println!("result: {:?}", result);
}
Output:
checking 1
checking 2
checking 3
checking 4
checking 5
checking 6
checking 7
checking 8
checking 9
checking 10
checking 11
checking 12
result: Some(12)
find pulls one element at a time from the infinite range through map, checks the predicate, and stops the instant it finds a match at 12 — it never asks for element 13 or beyond. If iterators were eager, this program would hang forever trying to build the whole mapped sequence before find even started looking.
Example 3: A Realistic Pipeline That Skips Unnecessary Work
Laziness is not just a curiosity for infinite ranges — it saves real work on ordinary, finite data too, whenever you don’t need every element.
fn main() {
let words = vec!["apple", "kiwi", "banana", "fig", "cherry", "date"];
let long_words: Vec<String> = words
.iter()
.inspect(|w| println!("looking at {}", w))
.filter(|w| w.len() > 4)
.map(|w| w.to_uppercase())
.take(2)
.collect();
println!("{:?}", long_words);
}
Output:
looking at apple
looking at kiwi
looking at banana
["APPLE", "BANANA"]
.inspect() is a passthrough adapter that lets us watch which elements actually flow through the pipeline. Notice that "fig", "cherry", and "date" are never inspected at all. As soon as .take(2) receives its second element ("BANANA"), it tells the pipeline it is satisfied, and collect() stops pulling. In a real program — searching a huge log file for the first few matching lines, say — this is the difference between scanning the whole file and stopping after a handful of lines.
How It Works Step by Step
Trace Example 3 to see the pull mechanism precisely. Each adapter’s next() calls the previous stage’s next(); values flow back up the chain one element at a time, never as a batch:
collect()callsnext()on theTakeiterator.Take(count so far: 0 of 2) callsnext()on theMapiterator.Mapcallsnext()on theFilteriterator.Filtercallsnext()on theInspectiterator, which callsnext()on the underlying slice iterator, producing"apple", and printslooking at apple.Filterchecks"apple".len() > 4(5 > 4, true) and passes it up.Mapconverts it to"APPLE".Takeaccepts it as its first element and returns it tocollect.collectasks for another element. The same chain runs again:"kiwi"is inspected and printed, but fails the length filter (4 is not > 4), soFilterimmediately asks the inner iterator for the next element without ever reachingMaporTakefor"kiwi"."banana"is inspected, passes the filter, becomes"BANANA", and is accepted asTake‘s second element.Takenow reports it is done.collectsees the chain is exhausted and returns the finishedVec<String>.
Iterators and the for Loop
A plain for x in some_iterator { ... } loop is not special syntax with its own rules — it desugars into a loop that repeatedly calls .next() and breaks when it sees None. That means a for loop is exactly as lazy as any other consumer: the adapters upstream of it only run as the loop body asks for each element.
Common Mistakes
Mistake 1: Building a Chain and Never Consuming It
Because adapters do nothing by themselves, it is easy to write a pipeline that silently does nothing at all.
let v = vec![1, 2, 3];
v.iter().map(|n| println!("{}", n)); // nothing is printed!
This compiles (with a warning that the iterator’s result is unused), but it prints nothing, because a Map iterator that nobody consumes never calls its closure. The fix is to use a consumer that actually drives the loop, such as for_each:
fn main() {
let v = vec![1, 2, 3];
v.iter().for_each(|n| println!("{}", n));
}
Output:
1
2
3
Mistake 2: Reusing an Iterator After It Has Been Consumed
Consuming methods like collect, sum, and count take the iterator by value (self, not &self), which means calling one of them moves the iterator. Trying to use that same iterator variable again afterward is a compile error, not a silent bug — the borrow checker’s ownership rules apply to iterators exactly like any other value.
fn main() {
let v = vec![1, 2, 3];
let iter = v.iter().map(|n| n * 2);
let doubled: Vec<i32> = iter.collect();
println!("{:?}", doubled);
let doubled_again: Vec<i32> = iter.collect(); // error: use of moved value `iter`
println!("{:?}", doubled_again);
}
The compiler rejects this with something like:
error[E0382]: use of moved value: `iter`
|
| let doubled: Vec<i32> = iter.collect();
| ---- value moved here
| let doubled_again: Vec<i32> = iter.collect();
| ^^^^ value used here after move
The fix is simply to build a fresh iterator each time you need one — v.iter() only borrows v, so it can be called as many times as you like:
fn main() {
let v = vec![1, 2, 3];
let doubled: Vec<i32> = v.iter().map(|n| n * 2).collect();
println!("{:?}", doubled);
let doubled_again: Vec<i32> = v.iter().map(|n| n * 2).collect();
println!("{:?}", doubled_again);
}
Output:
[2, 4, 6]
[2, 4, 6]
Mistake 3: Collecting an Unbounded Iterator
Laziness lets you build a pipeline over an infinite source, but calling an unbounded consumer like collect() on it directly is a real footgun — it will run forever and eventually exhaust memory, because collect keeps calling next() until it sees None, and a plain 1.. range never produces one.
// Warning: this compiles, but running it will loop forever and
// eventually exhaust all available memory. Do not run it.
let all: Vec<i32> = (1..).collect();
Always bound an unbounded iterator with something like .take(n) before handing it to an unbounded consumer:
fn main() {
let first_ten: Vec<i32> = (1..).take(10).collect();
println!("{:?}", first_ten);
}
Output:
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Best Practices
- Prefer chaining iterator adapters over hand-written index loops — the compiler fuses the whole chain into one pass with no intermediate allocations.
- Never call an unbounded consumer (
collect,sum,count) on an iterator that might be infinite without a.take(n),.take_while(...), or similar bound earlier in the chain. - When you only need to know whether or where something matches, use short-circuiting consumers like
find,any,all, orpositioninstead of collecting everything and searching afterward. - Don’t call
.collect()partway through a pipeline just to feed the next adapter — chain adapters directly and let laziness do a single pass. - Use
.inspect()temporarily while debugging to see exactly which elements a pipeline touches (and in what order), then remove it once you’re done. - Remember that
collect,sum,count,for_each,fold, andforloops all consume (move) the iterator — build a new iterator from the source collection each time you need to run a pipeline again.
Practice Exercises
- Given
let names = vec!["Al", "Priya", "Sam", "Fatima"];, use.find()(not.collect()followed by manual search) to get the first name with more than 3 characters. Expected result:Some("Priya"). - Using
(1..=1_000_000), build a pipeline with.filter(|x| x % 7 == 0)and.take(3), then.collect::<Vec<i32>>(). Explain, using what you learned about laziness, roughly how many of the million numbers the pipeline actually has to visit to produce its result. - The following code fails to compile:
let it = vec![1,2,3].iter().map(|n| n + 1); let a: Vec<i32> = it.collect(); let b: Vec<i32> = it.collect();. Explain which line causes the error and rewrite it so bothaandbcompile and both equal[2, 3, 4].
Summary
- Iterator adapters (
map,filter,take, and friends) are lazy: they build a description of work but perform none of it. - Only a consumer (
collect,sum,find,for_each, aforloop, …) actually callsnext()and drives the pipeline. - Laziness is a pull-based model: elements flow through the whole chain one at a time, which avoids intermediate allocations and makes the whole chain compile down to a single efficient loop.
- Laziness enables infinite iterators (like
1..) as long as you consume them with something that can stop early, such asfindortake. - Consuming methods take the iterator by value, so an iterator can only be driven to completion once; build a fresh one from the source if you need to run the pipeline again.
- Never call an unbounded consumer on an unbounded iterator without a
takeor similar bound first — it will hang and exhaust memory.
