Chaining Iterator Adapters

An iterator adapter is a method like map or filter that transforms an iterator into another iterator without immediately doing any work. Chaining several adapters together lets you describe a whole data-processing pipeline — filter, transform, limit, combine — in one readable expression, and Rust compiles that chain down to a tight loop with no wasted allocations. This lesson builds a correct mental model for how chained adapters actually execute, then works through several realistic pipelines and the mistakes beginners hit most often.

Overview: How Iterator Chaining Works

Every type that implements the Iterator trait exposes one required method, next(), which returns Option<Self::Item>Some(value) while there are more items, then None once exhausted. Everything else on Iteratormap, filter, enumerate, take, zip, and dozens more — is a default method built on top of next(). These are called adapters: each one wraps the iterator you call it on and returns a brand-new iterator struct (a Map, a Filter, and so on) that knows how to produce the next transformed value, but only when asked.

This is the single most important fact about iterator chains: adapters are lazy. Writing numbers.iter().map(|n| n * 2).filter(|n| n > &10) does not loop over numbers at all — it just builds a small nested structure of unevaluated steps, like stacking pipe fittings without turning on the water. Nothing flows until you attach a consumer: a method such as collect(), sum(), for_each(), count(), or a for loop, which repeatedly calls next() on the whole chain. Each call to the outermost next() ripples backward through every adapter, pulling exactly one item through the entire pipeline before the next one starts. Compare this to a language where list.map(...).filter(...) eagerly builds a whole new list at every step: Rust’s chain never allocates an intermediate collection, and because every adapter is a generic struct known at compile time, the compiler inlines the whole pipeline into a single loop with no dynamic dispatch. This is what people mean when they call iterators a "zero-cost abstraction" — the chained, readable version compiles to the same machine code as a hand-written loop.

There are three ways to start a chain from a collection, and picking the right one matters: .iter() borrows each element immutably (yielding &T), .iter_mut() borrows mutably (yielding &mut T), and .into_iter() takes ownership of the collection and yields owned T values, consuming the original variable. Mixing these up is a very common source of "value moved" errors, which we will trace through in Common Mistakes below.

Syntax

A chained pipeline is a sequence of method calls read top to bottom, ending in one call that actually drives iteration:

iterator_source
    .adapter_one(|item| ...)
    .adapter_two(|item| ...)
    .consumer_method()
  • iterator_source — anything implementing Iterator: vec.iter(), vec.into_iter(), a range like 0..10, str::chars(), etc.
  • adapter — a lazy method that returns a new iterator: map, filter, filter_map, enumerate, take, skip, zip, chain, rev, flat_map. You can chain as many as you like.
  • closure — the |item| ... argument most adapters take, describing what to do with each element.
  • consumer_method — an eager method that pulls every item through the chain: collect(), sum(), count(), for_each(), fold(), find(), any(), all(). Without one of these (or a for loop), the chain never runs.
Method Kind What it does
map Adapter Transforms each item with a closure
filter Adapter Keeps items where the closure returns true
filter_map Adapter Maps to an Option and keeps only the Some values
enumerate Adapter Pairs each item with its index as (usize, item)
take(n) Adapter Stops after the first n items
zip Adapter Pairs items from two iterators together
collect() Consumer Builds a collection (Vec, String, HashMap, …)
sum() / fold() Consumer Reduces the chain to a single value

Examples

Example 1: Filter then map then reduce

This pipeline keeps only the even numbers, squares them, and sums the result — three adapters and one consumer, none of which allocate an intermediate Vec.

fn main() {
    let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

    let sum_of_squares: i32 = numbers
        .iter()
        .filter(|&&n| n % 2 == 0)
        .map(|&n| n * n)
        .sum();

    println!("Sum of squares of even numbers: {}", sum_of_squares);
}

Output:

Sum of squares of even numbers: 220

numbers.iter() yields &i32 references, so filter‘s closure receives a reference to that, &&i32 — hence the |&&n| pattern that unwraps both layers down to a plain i32. filter does not change the item type, so map still receives &i32 and the single-& pattern |&n| is enough there. The kept, squared values are 4, 16, 36, 64, and 100, which sum() adds to 220.

Example 2: Enumerate, filter, and format strings

A more realistic pipeline: number each word, drop short ones, and build a formatted report.

fn main() {
    let words = vec!["rust", "is", "a", "systems", "programming", "language"];

    let result: Vec<String> = words
        .iter()
        .enumerate()
        .filter(|(_, word)| word.len() > 2)
        .map(|(i, word)| format!("{}: {}", i, word.to_uppercase()))
        .collect();

    for line in &result {
        println!("{}", line);
    }
}

Output:

0: RUST
3: SYSTEMS
4: PROGRAMMING
5: LANGUAGE

enumerate() pairs each word with its original index before any filtering happens, which is why the surviving indices (0, 3, 4, 5) are not renumbered from zero — they keep their position in the source list. filter drops "is" and "a" because their length is not greater than 2, and map formats the rest into owned Strings that collect() gathers into a Vec<String>.

Example 3: filter_map with take, and laziness in action

This pipeline parses a mix of valid and invalid number strings, keeps only the ones that parse successfully, and stops as soon as it has three.

fn main() {
    let inputs = vec!["3", "7", "abc", "12", "not_a_number", "5", "9"];

    let valid_numbers: Vec<i32> = inputs
        .iter()
        .filter_map(|s| s.parse::<i32>().ok())
        .take(3)
        .collect();

    println!("First three valid numbers: {:?}", valid_numbers);

    let total: i32 = valid_numbers.iter().sum();
    println!("Total: {}", total);
}

Output:

First three valid numbers: [3, 7, 12]
Total: 22

filter_map combines mapping and filtering: the closure returns Option<i32> (via .parse().ok(), which turns a failed parse into None), and only the Some values continue down the chain. Because everything is lazy and pulled one item at a time, take(3) stops the whole pipeline the instant it has collected three successes — "5" and "9" are never even visited. The three survivors are 3, 7, and 12 (skipping the unparsable "abc" and "not_a_number"), summing to 22.

How It Works Step by Step

Trace Example 3 to see the "pull, not push" model concretely. When collect() calls next() on the outermost adapter (take):

  1. take asks the inner filter_map for its next item.
  2. filter_map asks iter() for the next raw element, "3", tries to parse it — success — and returns 3. take now has 1 of 3.
  3. The same happens for "7", giving take its 2nd item.
  4. filter_map pulls "abc", parsing fails, so it discards it internally and immediately pulls again from iter() without reporting anything to take — a failed parse never counts as a produced item.
  5. "12" parses successfully and becomes take‘s 3rd and final item.
  6. take has now reached its limit of 3, so on the next call it returns None immediately — without ever asking filter_map for another value, which means "not_a_number", "5", and "9" are never touched at all.
  7. collect() sees None, stops looping, and returns the Vec<i32> it built from the three yielded values.

This is why chained adapters can be more efficient than writing separate loops: the pipeline short-circuits as a unit, and no step does more work than the final consumer actually demands.

Common Mistakes

Mistake 1: Building a chain but never consuming it

Because adapters are lazy, this compiles but does nothing useful — the compiler even warns that the Map iterator "must be used":

let numbers = vec![1, 2, 3, 4, 5];
numbers.iter().map(|n| n * 2);
println!("Doubled the numbers!");

Output:

Doubled the numbers!

No doubling ever happens — the map call just builds an iterator struct that is immediately dropped. The fix is to end the chain with a consumer:

let numbers = vec![1, 2, 3, 4, 5];
let doubled: Vec<i32> = numbers.iter().map(|n| n * 2).collect();
println!("Doubled: {:?}", doubled);

Output:

Doubled: [2, 4, 6, 8, 10]

Mistake 2: Using into_iter() when you still need the original collection

into_iter() takes ownership of the collection, moving it. Using the original variable afterward is a compile error, not a runtime bug:

fn main() {
    let words = vec![String::from("hello"), String::from("world")];
    let lengths: Vec<usize> = words.into_iter().map(|w| w.len()).collect();
    println!("{:?}", words);
}

Compiler output:

error[E0382]: borrow of moved value: `words`
  |
  |     let lengths: Vec<usize> = words.into_iter().map(|w| w.len()).collect();
  |                               ----- value moved here
  |     println!("{:?}", words);
  |                       ^^^^^ value borrowed here after move

into_iter() consumed words, so it no longer exists by the time println! tries to borrow it. Since the closure only needs to read each String‘s length, borrowing with .iter() instead of consuming with .into_iter() fixes it:

fn main() {
    let words = vec![String::from("hello"), String::from("world")];
    let lengths: Vec<usize> = words.iter().map(|w| w.len()).collect();
    println!("{:?}", words);
    println!("{:?}", lengths);
}

Output:

["hello", "world"]
[5, 5]

Mistake 3: Forgetting the extra reference inside filter

.iter() already yields references, and filter‘s closure receives a reference to that item — so on a Vec<i32>, the closure parameter ends up as &&i32, not &i32. Comparing it directly against a plain number fails to type-check:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5, 6];
    let big_numbers: Vec<&i32> = numbers.iter().filter(|x| x > 5).collect();
    println!("{:?}", big_numbers);
}

Compiler output:

error[E0308]: mismatched types
  |     let big_numbers: Vec<&i32> = numbers.iter().filter(|x| x > 5).collect();
  |                                                              ^ expected `&&i32`, found integer
  = note: cannot compare `&&i32` with `i32` using `>`

Dereferencing twice with **x gets down to the plain i32 the comparison actually needs:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5, 6];
    let big_numbers: Vec<&i32> = numbers.iter().filter(|x| **x > 5).collect();
    println!("{:?}", big_numbers);
}

Output:

[6]

An equally common fix is to add .copied() right after .iter() on a Copy type like i32, which turns the &i32 items into plain i32 up front and avoids the double-reference problem for every adapter after it.

Best Practices

  • Default to .iter() (or .iter_mut() when you need to mutate in place) and reach for .into_iter() only when you genuinely want to consume the collection.
  • Chain adapters directly instead of calling .collect() between every step — each intermediate collect() allocates a whole new collection that the next adapter immediately consumes.
  • Use filter_map instead of a separate filter + map + unwrap whenever a step can fail (parsing, lookups) — it keeps the fallible logic in one place.
  • Add an explicit type annotation (let x: Vec<i32> = ...) or a turbofish (.collect::<Vec<i32>>()) whenever collect()‘s target type can’t be inferred from context.
  • Reach for .copied() or .cloned() right after .iter() when working with references to small Copy types, to avoid double-reference patterns spreading through the rest of the chain.
  • Always end a chain with a consumer (collect, sum, for_each, a for loop, etc.) — an unconsumed chain does nothing and the compiler will warn you.

Practice Exercises

  1. Given let temps_c = vec![-5, 0, 15, 22, -1, 30];, write a chain that keeps only temperatures above freezing (greater than 0), converts each to Fahrenheit (f = c * 9 / 5 + 32), and collects the results into a Vec<i32>. Expected output for these inputs: [59, 71, 86].
  2. Given let names = vec!["Al", "Grace", "Bo", "Ada", "Sam"];, use enumerate, filter (keep names with more than 2 characters), and map to build greeting strings like "1: Hello, Grace!", then collect() them into a Vec<String> and print each one.
  3. Given let raw = vec!["10", "x", "20", "y", "30"];, use filter_map to parse valid integers and fold (starting from 0) to sum them directly, without ever calling collect(). Expected total: 60.

Summary

  • Iterator adapters like map, filter, filter_map, enumerate, and take are lazy — they build a pipeline but do no work on their own.
  • A consumer such as collect(), sum(), for_each(), or fold() is what actually drives iteration, pulling one item through the whole chain at a time.
  • Because the chain executes as a single pull-based loop, it can short-circuit (as take does) and never allocates hidden intermediate collections.
  • .iter() borrows, .into_iter() takes ownership, and .iter_mut() borrows mutably — choosing the wrong one is the most common source of chain-related compile errors.
  • Watch for double references (&&T) when a closure in filter or similar methods receives a reference to an already-borrowed item.
  • An unconsumed chain is a silent no-op, not a crash — always finish with a consumer method.