The pipeline that runs at light speed
You have a vector of transaction records. You need to find all the failed ones, extract the amounts, double them for a penalty calculation, and sum the total. Your instinct might be to write a for loop, push matching items into a temporary Vec, loop over that, transform, and accumulate. That works. It also allocates memory twice, touches memory three times, and gives the optimizer less to work with.
Rust gives you a better path. Iterator adapters let you describe the pipeline. The compiler turns that description into a single, fused loop that runs as fast as hand-written assembly, often faster. You get the clarity of declarative code with the performance of imperative loops. No intermediate allocations. No hidden overhead. Just one tight pass over the data.
Lazy evaluation: the plan, not the work
Iterator adapters are lazy. Calling .filter() doesn't run the filter. It returns a new iterator object that knows how to filter. Calling .map() on top of that returns another iterator. Nothing happens until you call a consumer like .collect() or .sum().
Think of an assembly line. The adapters are the stations. The source is the raw material. The consumer is the box at the end that takes the finished product. When the box asks for an item, the last station asks the previous station. That station asks the one before it. The request ripples back to the source. The source produces one value. It flows up the chain. If a station rejects it, the work stops immediately. The consumer never sees it. The previous stations never process it.
This pull-based model has two consequences. First, you can process infinite streams. The chain only produces what the consumer asks for. Second, the compiler sees the entire chain at once. It knows exactly how data flows from source to consumer. That visibility enables loop fusion.
Minimal example: chaining without allocation
/// Calculate the sum of doubled even numbers from a list.
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
// Chain adapters: filter keeps evens, map doubles them.
// Nothing runs yet. This builds the pipeline structure in memory.
// The result is an iterator object, not a Vec.
let pipeline = numbers.iter()
.filter(|&&x| x % 2 == 0)
.map(|&x| x * 2);
// .sum() is a consumer. It pulls values through the pipeline one by one.
// The compiler fuses this into a single loop with no intermediate Vec.
// Total work: one pass, zero heap allocations for intermediates.
let total: i32 = pipeline.sum();
println!("Total: {}", total); // Output: Total: 12 (2*2 + 4*2)
}
Notice the closure signatures. numbers.iter() yields &i32. The filter adapter takes a closure that accepts &T, where T is the item type. Since T is &i32, the argument is &&i32. The pattern &&x destructures the double reference to get the value. The map adapter takes a closure that accepts T, so the argument is &i32. The pattern &x destructures once.
This reference juggling is a common friction point for newcomers. The convention is to match the pattern to the reference depth. If you use iter(), expect one extra & in filter and one in map. If you use into_iter(), the types shift, and you own the values. Stick to iter() for read-only pipelines. It avoids moving data and keeps the original collection alive.
What the compiler sees: loop fusion
When you compile the example above, the LLVM optimizer analyzes the chain. It recognizes the pattern. It performs loop fusion. Instead of generating code for a filter loop, then a map loop, then a sum loop, it generates one loop.
Inside that loop, the compiler inlines the closures. It checks the condition. If true, it applies the map and adds to the accumulator. If false, it skips. The result is equivalent to this hand-written loop:
let mut total = 0;
for &x in &numbers {
if x % 2 == 0 {
total += x * 2;
}
}
But you didn't write the loop. You wrote the intent. The compiler handled the mechanics. This fusion eliminates the overhead of multiple passes. It also eliminates the allocation of intermediate collections. In Python, [x*2 for x in nums if x%2==0] creates a list. In Rust, the iterator chain creates zero heap allocations for the intermediate steps.
The fused loop is also easier for the compiler to vectorize. When the operations are simple and independent, LLVM can emit SIMD instructions that process multiple items in parallel. The iterator chain gives the optimizer the complete picture it needs to make that decision.
Laziness saves work: short-circuiting
Laziness isn't just about allocation. It's about doing less work. Some consumers stop early. any checks if any item satisfies a predicate. find searches for the first match. take limits the number of items. These consumers short-circuit. They stop pulling from the pipeline as soon as they have what they need.
/// Check if any number is divisible by 7, stopping early.
fn has_multiple_of_seven(numbers: &[i32]) -> bool {
// .any() pulls items one by one.
// As soon as the predicate returns true, .any() returns true.
// The rest of the list is never touched.
numbers.iter().any(|&x| x % 7 == 0)
}
/// Take the first three even numbers from an infinite range.
fn first_three_evens() -> Vec<i32> {
// (0..) is an infinite iterator.
// .filter() checks each number.
// .take(3) stops after three matches.
// The infinite range is never exhausted.
(0..)
.filter(|&x| x % 2 == 0)
.take(3)
.collect()
}
In the first example, if the list has a million items and the first one is divisible by 7, the function returns immediately. A for loop with a break does the same, but any is clearer. It expresses the intent directly. In the second example, take(3) ensures the pipeline stops after three items. The infinite range produces values on demand. The chain processes exactly what is needed.
Laziness allows you to compose pipelines without worrying about the size of the input. You can chain filter, map, take, and collect on a stream that could be gigabytes long. The memory usage stays constant. The work scales with the output, not the input.
Realistic scenario: processing structured data
Iterators shine when processing structured data. You often need to filter, transform, and aggregate records. The chain keeps the logic localized and performant.
/// Represents a user record in a system.
#[derive(Debug)]
struct User {
id: u32,
name: String,
is_active: bool,
age: u8,
}
/// Count active users over 18 using iterator adapters.
fn count_active_adults(users: &[User]) -> usize {
// .iter() borrows the slice.
// .filter() keeps active users.
// .filter() keeps adults.
// .count() consumes the iterator and returns the length.
// No intermediate Vec. Single pass.
users.iter()
.filter(|u| u.is_active)
.filter(|u| u.age > 18)
.count()
}
/// Collect unique names of active users.
fn active_user_names(users: &[User]) -> Vec<String> {
// .iter() borrows the slice.
// .filter() keeps active users.
// .map() clones the name. Cloning is necessary because
// we want owned Strings in the result, not references.
// .collect() gathers the results into a Vec.
// The turbofish <Vec<String>> tells the compiler the target type.
users.iter()
.filter(|u| u.is_active)
.map(|u| u.name.clone())
.collect::<Vec<String>>()
}
The collect method is generic. It implements FromIterator for many types: Vec, HashSet, HashMap, String, and more. The compiler often infers the type from the return type or the variable annotation. When it can't, you need a turbofish. collect::<Vec<String>>() is the standard way to disambiguate. The community convention is to use the explicit type in the turbofish when inference fails. It makes the code self-documenting.
If you need uniqueness, collect into a HashSet. The set handles deduplication. You can convert it back to a Vec if order matters, or leave it as a set if you just need membership checks. The iterator chain stays clean. The collection type handles the semantics.
Pitfalls and compiler errors
Iterators are powerful, but they have traps. The borrow checker and type inference can trip you up.
Type annotations for collect
If you call collect() without enough context, the compiler rejects you with E0282 (type annotations needed). collect doesn't know whether you want a Vec, a HashSet, or a String. You must specify the type.
let result = users.iter().map(|u| &u.name).collect();
// Error[E0282]: type annotations needed
// The compiler sees multiple possible types for the result.
Fix this with a turbofish or a type annotation on the variable. let result: Vec<&str> = ... works. collect::<Vec<&str>>() works. Pick one and stick with it.
Borrowing conflicts
Iterators borrow the collection. You cannot mutate the collection while iterating. If you try to push to a Vec while iterating over it, you get E0502 (cannot borrow as mutable because it is also borrowed as immutable).
let mut users = vec![User::new()];
// Error[E0502]: cannot borrow `users` as mutable because it is also borrowed as immutable
for u in &users {
users.push(User::new());
}
Iterators require stable references. Mutation can invalidate those references. If you need to modify the collection, collect the results first, then mutate. Or use iter_mut if you are modifying items in place.
for_each versus for loops
for_each is a consumer that takes a closure. It looks like a loop, but it's an adapter. It calls the closure for each item. This can be slower than a for loop because of closure call overhead. The compiler inlines closures, but a for loop is structurally simpler.
Use for loops for side effects like printing, logging, or mutating external state. Use for_each only when you are chaining adapters and need a consumer that returns (). The community convention is to prefer for loops for standalone side effects. It's clearer and often faster.
Reference depth confusion
As mentioned earlier, iter() yields &T. filter takes &T. map takes T. This means filter gets &&T and map gets &T. If you forget the destructuring, you end up with references where you want values. The compiler will complain with E0277 (trait bound not satisfied) if you try to use a reference as a value.
Check the closure signature. Match the pattern to the reference depth. &&x for filter on iter(). &x for map on iter(). This pattern holds for most chains.
Decision: iterators versus loops
Use iterator adapters when you transform data from one collection to another. The compiler fuses the loops and avoids intermediate allocations. The code is declarative and less error-prone.
Use a for loop when you need to perform side effects like printing, logging, or mutating external state. The for loop is clearer and avoids the overhead of closure calls in for_each.
Use into_iter when you want to consume the collection and take ownership of the items. This moves values out instead of borrowing references. It's useful when you need owned data in the pipeline.
Use iter_mut when you need to modify the items in place. This gives you mutable references through the pipeline. You can update fields without cloning.
Reach for for_each only when you are chaining adapters and need a consumer that returns (). Prefer a for loop for standalone side effects.
Iterators are for data transformation. Loops are for actions. Keep them separate. Write the chain, let LLVM do the heavy lifting.