I remember the first time I chained five .map() and .filter() calls in Rust and expected my terminal to lag from all that “processing.” It didn’t. Nothing happened at all — until I called .collect() at the end, and suddenly the whole pipeline ran in one pass. That was my introduction to lazy evaluation, and it’s the single idea that makes Rust’s iterator system feel less like a library feature and more like a part of the language itself.
In this article, I want to walk through how iterators actually work under the hood in Rust, why laziness matters for performance, the difference between adapters and consumers, and how all of this ties back into ownership and zero-cost abstractions.
What Is an Iterator, Really?
At its core, an iterator in Rust is just a type that implements one trait:
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
That’s it. One associated type, one method. Everything else — map, filter, fold, sum, collect, dozens of others — is a default method built on top of next(). This is why implementing Iterator for your own type gives you an entire toolbox for free.
struct Countdown(u32);
impl Iterator for Countdown {
type Item = u32;
fn next(&mut self) -> Option<u32> {
if self.0 == 0 {
None
} else {
self.0 -= 1;
Some(self.0 + 1)
}
}
}
fn main() {
let countdown = Countdown(5);
for n in countdown {
println!("{}", n);
}
}
Output:
5
4
3
2
1
Lazy Evaluation: Nothing Runs Until You Ask
This is the part that surprised me most. Consider:
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
let pipeline = numbers.iter()
.map(|x| {
println!("mapping {}", x);
x * 2
})
.filter(|x| {
println!("filtering {}", x);
x % 3 == 0
});
println!("Pipeline built. Nothing has run yet.");
let result: Vec<i32> = pipeline.collect();
println!("{:?}", result);
}
Output:
Pipeline built. Nothing has run yet.
mapping 1
filtering 2
mapping 2
filtering 4
mapping 3
filtering 6
mapping 4
filtering 8
mapping 5
filtering 10
[6]
Notice how map and filter interleave per element, rather than running fully separately. Each element flows through the entire chain before the next element starts. This is lazy evaluation in action: calling .map() or .filter() doesn’t loop over anything — it just wraps the previous iterator in a new struct that knows how to produce the next transformed value when asked. Nothing executes until something consumes the iterator, like .collect().
Why This Matters for Performance
Because adapters are lazy and generic over the underlying iterator type, the Rust compiler can often inline the entire chain into a single tight loop with no intermediate allocations. This is what “zero-cost abstraction” means in practice — the high-level .map().filter().sum() chain compiles down to roughly the same machine code as a hand-written for loop with manual if checks. I’ve genuinely checked this with cargo asm on small examples, and the generated code is nearly identical to the imperative version.
Adapters vs. Consumers
I think of iterator methods in two buckets:
- Adapters — take an iterator, return a new iterator. Lazy. Examples:
map,filter,enumerate,zip,take,skip,chain,rev. - Consumers — take an iterator, produce a final, non-iterator value. Eager — they actually run the pipeline. Examples:
collect,sum,count,fold,for_each,find.
fn main() {
let words = vec!["rust", "is", "fun", "and", "fast"];
// Adapters: build a lazy pipeline
let long_words = words.iter()
.filter(|w| w.len() > 2)
.map(|w| w.to_uppercase());
// Consumer: actually runs it
let result: Vec<String> = long_words.collect();
println!("{:?}", result);
}
Output:
["RUST", "FUN", "AND", "FAST"]
Until .collect() (a consumer) is called, long_words is just a description of work to be done, not the work itself.
Common Adapters I Use Constantly
fn main() {
let nums = vec![1, 2, 3, 4, 5, 6];
// enumerate: pair each item with its index
for (i, n) in nums.iter().enumerate() {
println!("index {}: {}", i, n);
}
// zip: combine two iterators pairwise
let letters = vec!['a', 'b', 'c'];
let zipped: Vec<(i32, char)> = nums.iter().cloned().zip(letters).collect();
println!("{:?}", zipped);
// take / skip
let first_three: Vec<&i32> = nums.iter().take(3).collect();
let after_three: Vec<&i32> = nums.iter().skip(3).collect();
println!("{:?} / {:?}", first_three, after_three);
}
Output:
index 0: 1
index 1: 2
index 2: 3
index 3: 4
index 4: 5
index 5: 6
[(1, 'a'), (2, 'b'), (3, 'c')]
[1, 2, 3] / [4, 5, 6]
Common Consumers I Use Constantly
fn main() {
let nums = vec![1, 2, 3, 4, 5];
let total: i32 = nums.iter().sum();
let product: i32 = nums.iter().product();
let max = nums.iter().max();
let found = nums.iter().find(|&&x| x > 3);
println!("sum: {}, product: {}, max: {:?}, first > 3: {:?}", total, product, max, found);
// fold: the most general consumer — build any accumulated value
let joined = nums.iter().fold(String::new(), |mut acc, n| {
acc.push_str(&n.to_string());
acc.push('-');
acc
});
println!("{}", joined);
}
Output:
sum: 15, product: 120, max: Some(5), first > 3: Some(4)
1-2-3-4-5-
fold deserves special mention — nearly every other consumer (sum, count, max) can be expressed in terms of fold. Once I understood fold, the rest of the consumer methods felt like convenient named shortcuts rather than separate concepts to memorize.
Ownership: iter(), into_iter(), and iter_mut()
This tripped me up constantly as a beginner, so it’s worth being explicit:
fn main() {
let v = vec![1, 2, 3];
// iter(): borrows each element as &T
for x in v.iter() {
println!("borrowed: {}", x);
}
// iter_mut(): borrows each element as &mut T
let mut v2 = vec![1, 2, 3];
for x in v2.iter_mut() {
*x *= 10;
}
println!("{:?}", v2);
// into_iter(): takes ownership, yields T
for x in v.into_iter() {
println!("owned: {}", x);
}
// v is no longer usable here — it was moved
}
Output:
borrowed: 1
borrowed: 2
borrowed: 3
[10, 20, 30]
owned: 1
owned: 2
owned: 3
for x in &v is sugar for v.iter(), and for x in v is sugar for v.into_iter(). Once that clicked, most of my confusion about “why won’t this compile, I just used v in a loop” disappeared — the loop had consumed v by value.
A Real-World Example: Processing Log Lines
fn main() {
let log = "\
2024-01-01 INFO server started
2024-01-01 ERROR failed to bind port
2024-01-02 INFO connection accepted
2024-01-02 ERROR timeout on request";
let error_count = log
.lines()
.filter(|line| line.contains("ERROR"))
.count();
let error_messages: Vec<&str> = log
.lines()
.filter(|line| line.contains("ERROR"))
.map(|line| line.splitn(3, ' ').last().unwrap())
.collect();
println!("Total errors: {}", error_count);
println!("Messages: {:?}", error_messages);
}
Output:
Total errors: 2
Messages: ["failed to bind port", "timeout on request"]
This is the pattern I use daily — filtering and transforming text data with an iterator chain instead of manual loops with mutable accumulator variables. It reads almost like a description of intent rather than a set of steps.
Best Practices and Idiomatic Patterns
- Prefer iterator chains over manual indexing loops. They’re less error-prone (no off-by-one bugs) and just as fast after compiler optimization.
- Use
iter()by default, reach forinto_iter()only when you genuinely need ownership of the elements, anditer_mut()only when you need to mutate in place. - Avoid
.collect::<Vec<_>>()in the middle of a chain unless you actually need the intermediateVec— it forces eager evaluation and an allocation you probably don’t need. - Use
foldfor custom accumulation logic rather than a mutable variable plus aforloop, when it improves readability.
Common Mistakes
- Forgetting iterators are lazy and expecting a
.map()call alone to “do work” — it won’t do anything until consumed. - Calling
.collect()too eagerly, turning what could be one pass into several passes with intermediate allocations. - Confusing
iter()andinto_iter(), leading to “value moved” compiler errors that seem to appear out of nowhere. - Using
.unwrap()on.find()or.max()results without considering theNonecase for an empty collection.
FAQs and Troubleshooting
Q: Why doesn’t my iterator chain print anything? A: You probably didn’t call a consumer. .map() and .filter() alone build a lazy pipeline; add .collect(), .for_each(), or a for loop to actually run it.
Q: What’s the difference between .iter() and .into_iter() on a Vec? A: .iter() yields references (&T) and leaves the vector usable afterward. .into_iter() consumes the vector and yields owned values (T).
Q: Are iterator chains actually as fast as a for loop? A: In release builds, yes — almost always. The compiler aggressively inlines and optimizes iterator chains into equivalent machine code as hand-written loops, thanks to Rust’s zero-cost abstraction guarantees. Always benchmark with --release; debug builds don’t inline as aggressively.
Q: How do I write my own custom iterator adapter? A: Define a struct wrapping the inner iterator, implement Iterator for it, and have next() call the inner iterator’s next() while applying your transformation.
Summary
Rust’s iterator system is where I finally understood what “zero-cost abstraction” means in practice: the Iterator trait boils down to a single next() method, adapters lazily wrap one iterator inside another, and nothing actually runs until a consumer pulls values through the chain. Once that mental model settles in, iterator chains stop feeling like magic and start feeling like the most natural way to express data transformations in Rust — often compiling to code just as fast as, or faster than, a manually written loop.
