Skip to lesson

learningrust.org / intermediate / 15-iterators · lesson 15 of 26

TL;DR

Master Rust's powerful iterator system to write expressive, efficient, and functional-style code

Key concepts

  • Rust iterators
  • Rust iterator trait
  • Rust map filter
  • Rust functional programming
  • Rust iterator adapters

Iterators

Iterators are one of Rust's most powerful and elegant features. They let you process sequences of elements in a composable way — when you compile with optimizations on, the compiler collapses iterator chains down to the same machine code as a hand-written loop.

What You'll Learn

By the end of this lesson you will take a slice of raw log lines and, in three chains, measure them, select the ones that matter and collapse them to a single number — without writing a single index or a manual accumulator. The part that catches people is that none of that work happens where you write it: an adapter builds a value and stops, and nothing runs until something on the end of the chain demands an element.

This is the capability the capstone's taskwork leans on more heavily than any other on the track: it holds its tasks in one list and answers every question about them with an adapter chain — its select is exactly iter, filter, collect, and it hands back borrowed views rather than copies. You arrive here able to store records in a Vec and a HashMap (Collections) and to say whether a method borrows its receiver or consumes it (Ownership & Borrowing) — that second one decides which of iter, iter_mut and into_iter a chain may start with, and it is the first thing to check when a chain will not compile.

What Is an Iterator?

An iterator is any type that implements the Iterator trait, which requires a single method: next(). Each call to next() returns Some(item) until the sequence is exhausted, then returns None.

fn main() {
    let numbers = vec![10, 20, 30, 40, 50];
    let mut iter = numbers.iter();

    println!("{:?}", iter.next()); // Some(10)
    println!("{:?}", iter.next()); // Some(20)
    println!("{:?}", iter.next()); // Some(30)
    println!("{:?}", iter.next()); // Some(40)
    println!("{:?}", iter.next()); // Some(50)
    println!("{:?}", iter.next()); // None
}

In practice, you rarely call next() manually. Rust's for loop and iterator adapters handle that for you.

Creating Iterators

There are three common ways to get an iterator from a collection:

  • .iter() — borrows each element as &T
  • .iter_mut() — borrows each element as &mut T
  • .into_iter() — consumes the collection, yielding T
fn main() {
    let fruits = vec!["apple", "banana", "cherry"];

    // Borrow — fruits is still usable after the loop
    for fruit in fruits.iter() {
        println!("Borrowed: {}", fruit);
    }

    // into_iter on a reference gives &T too (common in for loops)
    for fruit in &fruits {
        println!("Via reference: {}", fruit);
    }

    println!("fruits still alive: {:?}", fruits);
}

Iterator Adapters

Iterator adapters are methods that transform one iterator into another. They are lazy — nothing runs until you consume the iterator.

Laziness is easy to nod along to and hard to actually believe, so before reading on, trace the exact order of the printed lines below. The closure inside map prints, and so does the code around it.

Predict

The closure inside map prints a line for each element. Work out the exact order of every printed line before you run it.

fn main() {
  let scores = vec![70, 85, 90];

  let doubled = scores.iter().map(|s| {
      println!("doubling {}", s);
      s * 2
  });

  println!("chain built");

  let total: i32 = doubled.sum();
  println!("total {}", total);
}
Continue learning

chain built prints first. Writing .map(...) builds a value that merely remembers the source iterator and the closure — the closure has not run even once at that point. It is sum() that drives the whole thing: it calls next() repeatedly, and each next() pulls one element through map, which is when the closure finally runs and prints. The doubled total is (70 + 85 + 90) * 2 = 490. The practical consequence is blunt: an adapter chain with no consuming adapter on the end does nothing at all.

map and filter — Transform, and Keep Only What Matches

map applies a closure to every element; filter takes a closure that returns true to keep an element or false to skip it. Two stages worked below, and as in Strings and Text the comments say why each stage was reached for rather than what its lines do:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];

    // Stage 1: map alone. The closure takes &n rather than n because .iter()
    // yields references - the pattern does the dereferencing so the body can
    // do arithmetic on a plain i32.
    let doubled: Vec<i32> = numbers
        .iter()
        .map(|&n| n * 2)
        .collect();

    println!("Original: {:?}", numbers);
    println!("Doubled:  {:?}", doubled);

    // Stage 2: filter before map. Chosen over stage 1 because the work is
    // now conditional - and filter comes FIRST so that map only runs on the
    // elements that survive, rather than squaring five numbers and throwing
    // three away. The closure takes &&n because filter hands its closure a
    // reference to the reference .iter() already yielded.
    let squared_evens: Vec<i32> = numbers
        .iter()
        .filter(|&&n| n % 2 == 0)
        .map(|&n| n * n)
        .collect();

    println!("Squared evens: {:?}", squared_evens);
}

It prints Doubled: [2, 4, 6, 8, 10] and Squared evens: [4, 16].

Now do the third stage yourself, and no comment is coming with it. Write stage 2 the other way round — .map(|&n| n * n) first, then .filter(|n| n % 2 == 0) — and before you run it, write down whether the printed list changes and how many times the closures run. Then run it. The list is unchanged: it is still [4, 16], because squaring preserves evenness, so the two orders agree on this input by arithmetic accident rather than by rule. What does change is the count — five squarings instead of two — and the reason the stage 2 above is written filter-first is that this is the one thing the output will never tell you. Hold on to which of the two facts the program can show you and which it cannot; the next section turns exactly that gap into a bug.

enumerate — Track Position

enumerate wraps each element with its index, yielding (index, item) pairs.

fn main() {
    let tasks = vec!["write tests", "fix bug", "deploy", "review PR"];

    for (i, task) in tasks.iter().enumerate() {
        println!("Task {}: {}", i + 1, task);
    }

    // Find the index of the first task containing "bug"
    let bug_task = tasks
        .iter()
        .enumerate()
        .find(|(_, task)| task.contains("bug"));

    match bug_task {
        Some((i, task)) => println!("Found at position {}: {}", i, task),
        None => println!("No bug tasks found"),
    }
}

Notice where .enumerate() sits in that chain — before the search, not after. Adapters are applied in the order you write them, and each one sees only what the previous one passed along. The next program gets that order wrong. It compiles without a single warning and prints a perfectly plausible answer, which is what makes it dangerous. Commit a hypothesis before you change anything:

Debug

This should report where each 'fix' task sits in the ORIGINAL list — positions 1 and 4. It compiles cleanly and prints numbers that look reasonable, but they are the wrong numbers. Say why before you change anything, then fix it.

fn main() {
  let tasks = vec!["write spec", "fix bug", "deploy", "review PR", "fix typo"];

  // Report the position of every task that mentions "fix", using the
  // position it holds in the ORIGINAL list.
  let positions: Vec<usize> = tasks
      .iter()
      .filter(|t| t.contains("fix"))
      .enumerate()
      .map(|(i, _)| i)
      .collect();

  assert_eq!(
      positions,
      vec![1, 4],
      "expected the original positions [1, 4], got {:?}",
      positions
  );
  println!("Fix tasks at: {:?}", positions);
}

Expected output: Fix tasks at: [1, 4]

Continue learning

The bug is the adapter order. enumerate does not look up an original index — it just counts whatever arrives at it, starting from 0. Because filter ran first, only the two matching tasks ever reached enumerate, so it numbered those two 0 and 1 and the program printed [0, 1]. Putting .enumerate() before .filter() lets it number all five tasks first, and filtering afterwards keeps the pairs at indices 1 and 4. Each adapter sees only what the previous one handed it, so where you place enumerate decides what "position" even means.

Consuming Adapters

Consuming adapters call next() and exhaust the iterator. Common ones include:

MethodPurpose
.collect()Gather into a collection
.sum()Add all elements
.count()Count elements
.any()True if any element matches
.all()True if all elements match
.find()First matching element
.fold()Reduce with an accumulator
fn main() {
    let scores = vec![85, 92, 78, 95, 88, 60, 73];

    let total: i32 = scores.iter().sum();
    let count = scores.len();
    let average = total / count as i32;

    let passing: Vec<&i32> = scores.iter().filter(|&&s| s >= 70).collect();
    let all_pass = scores.iter().all(|&s| s >= 50);
    let has_distinction = scores.iter().any(|&s| s >= 90);

    println!("Total: {}, Average: {}", total, average);
    println!("Passing scores: {:?}", passing);
    println!("All above 50: {}", all_pass);
    println!("Has distinction (>=90): {}", has_distinction);

    // fold: carry the largest score seen so far, starting from the smallest i32
    let max_score = scores.iter().fold(i32::MIN, |acc, &s| acc.max(s));
    println!("Highest score: {}", max_score);
}

Implementing Your Own Iterator

You can make any struct iterable by implementing Iterator:

struct Countdown {
    count: u32,
}

impl Countdown {
    fn new(start: u32) -> Self {
        Countdown { count: start }
    }
}

impl Iterator for Countdown {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        if self.count == 0 {
            None
        } else {
            let current = self.count;
            self.count -= 1;
            Some(current)
        }
    }
}

fn main() {
    let countdown = Countdown::new(5);

    // Every adapter method comes with the trait — none of them were written here
    let values: Vec<u32> = countdown.collect();
    println!("Countdown: {:?}", values);

    // Use adapters on your custom iterator
    let even_counts: Vec<u32> = Countdown::new(10)
        .filter(|n| n % 2 == 0)
        .collect();
    println!("Even counts: {:?}", even_counts);
}

Because Countdown implements Iterator, it automatically gains access to all iterator adapter methods like map, filter, take, zip, and dozens more.

Chaining Iterators

Two useful adapters for combining iterators:

  • .chain() — concatenate two iterators
  • .zip() — pair elements from two iterators
fn main() {
    let first = vec![1, 2, 3];
    let second = vec![4, 5, 6];

    // chain: iterate both sequences as one
    let combined: Vec<i32> = first.iter().chain(second.iter()).copied().collect();
    println!("Chained: {:?}", combined);

    // zip: pair elements together
    let names = vec!["Alice", "Bob", "Carol"];
    let scores = vec![95, 87, 92];

    let leaderboard: Vec<(&str, i32)> = names
        .iter()
        .copied()
        .zip(scores.iter().copied())
        .collect();

    for (name, score) in &leaderboard {
        println!("{}: {}", name, score);
    }
}

Every chain in this lesson so far has started with .iter(), and that choice is doing more work than it looks. Close the page and answer this from memory — it is really a question about ownership, from Ownership & Borrowing:

Recall

Without scrolling up: in Ownership & Borrowing you learned that assigning a Vec to a new binding MOVES it, so the original name is no longer usable. Now apply that to iterators. You have a vector of Strings called names. One version writes names.iter(), the other writes names.into_iter(). Which statement is right about using names again on the line AFTER the chain?

Continue learning

iter() borrows, so names is still usable after the chain; into_iter() takes the collection by value and moves it, so the original binding is gone. This is not a new rule — it is the move-versus-borrow rule from Ownership & Borrowing reached through a method call, because into_iter is declared as fn into_iter(self) while iter is declared as fn iter(&self). It also explains what each one yields: a borrowing iterator can only hand out &T, while a consuming one owns the elements and can hand out owned T values.

Assembling a Chain

The debug task above turned on where .enumerate() sat relative to .filter(). That was not a one-off: the order of adapters in a chain is part of its meaning, because each adapter is typed by what the one above it yields. The chain below is the same shape, shuffled. Its four fragments hang off lines.iter(), so no line introduces a name for the next one to consume — what fixes the order is what each adapter hands on.

Here is the frame the four fragments drop into:

let lines = vec!["ok start", "ERR disk", "ok sync", "ERR net", "ok done"];
let report: Vec<String> = lines
    .iter()
    // the four shuffled fragments go here, one per line
println!("{}", report.join(" | "));

Arrange the code

These four fragments hang off lines.iter() and report every ERR line with the position it holds in the ORIGINAL list. Notice what is NOT here: no fragment declares a binding, so nothing is ordered by one line naming another. Put them in the order that runs, then answer why this order and not another — for each adjacent pair, say what the upper adapter yields and why the lower one cannot accept anything else.

  1. .enumerate()
  2. .map(|(i, l)| format!("line {}: {}", i + 1, l))
  3. .collect();
  4. .filter(|(_, l)| l.starts_with("ERR"))
Continue learning

The finished chain prints line 2: ERR disk | line 4: ERR net — the positions the two failures hold in the original five-line list, which is what .enumerate() sitting above .filter() buys. Move .enumerate() below .filter() and adjust the closure patterns so it compiles again, and it prints line 1 and line 2 instead: correct code for a different question. That is worth stating as a rule, because it generalises past enumerate: an adapter sees only what the adapter above it passed along, so where you put it decides what it is talking about.

Try It Yourself

Reading about iterators is not the same as reaching for one by intent. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out, each one a single iterator chain over a slice of log lines. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each one until every check passes and it prints All checks passed.

The three functions use exactly what this lesson taught: map to reshape every element, filter chained into map to select and then reshape, and a consuming adapter to collapse a chain into a single number. The starter has the data, the stubs and the checks; you write only the body of each function. Nothing above spells out all three answers, so you will have to assemble them yourself.

Build

Finish the build. Three functions are stubbed out and the checks below them fail until each one returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

// A tiny log-line workbench. Each function is ONE iterator chain.

// TODO 1: return the LENGTH of every line, in order.
//   line_lengths(&["ab", "cde"]) -> vec![2, 3]
fn line_lengths(lines: &[&str]) -> Vec<usize> {
  // your code here
  Vec::new()
}

// TODO 2: return only the lines that start with "ERROR", uppercased,
//   in their original order. The str method starts_with does the test.
//   shout_errors(&["ERROR disk", "ok"]) -> vec!["ERROR DISK".to_string()]
fn shout_errors(lines: &[&str]) -> Vec<String> {
  // your code here
  Vec::new()
}

// TODO 3: return the total number of characters across every line.
//   Use ONE consuming adapter — no for loop, no mutable counter.
//   total_chars(&["ab", "cde"]) -> 5
fn total_chars(lines: &[&str]) -> usize {
  // your code here
  0
}

// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
  let lines = ["ERROR disk full", "info: ok", "ERROR timeout", "warn: slow"];

  assert_eq!(
      line_lengths(&lines),
      vec![15, 8, 13, 10],
      "line_lengths should map every line to its length, in order"
  );
  assert_eq!(
      shout_errors(&lines),
      vec!["ERROR DISK FULL".to_string(), "ERROR TIMEOUT".to_string()],
      "shout_errors should filter to the ERROR lines and map them to uppercase"
  );
  assert_eq!(
      total_chars(&lines),
      46,
      "total_chars should sum every line's length into one number"
  );

  println!("All checks passed.");
  println!("Lengths: {:?}", line_lengths(&lines));
  println!("Errors: {:?}", shout_errors(&lines));
  println!("Total chars: {}", total_chars(&lines));
}

Expected output: All checks passed. Lengths: [15, 8, 13, 10] Errors: ["ERROR DISK FULL", "ERROR TIMEOUT"] Total chars: 46

Continue learning

Once it passes, try two variations and predict each before running:

  1. Swap the order of filter and map. In shout_errors, move .map(|l| l.to_uppercase()) in front of .filter(...), keeping the test starts_with("ERROR"). Predict what comes back before running. The check still passes, because uppercasing does not change whether a line starts with ERROR — but the closure types shift (filter now sees a &String rather than a &&str) and every line gets uppercased, including the ones about to be thrown away. Same answer, more work: order matters for cost even when it does not change the result.
  2. Drop the consuming adapter. In total_chars, delete .sum() and try to return lines.iter().map(|l| l.len()). Decide what the compiler will say before running. It does not compile: the function promises a usize but the expression is a Map iterator, so rustc reports a mismatched type and names the adapter struct in the message. That is the laziness rule with teeth — without a consuming adapter you are holding a recipe, not a result.

Reading an Adapter-Type Error

That second variation is worth stopping on, because it produces the message adapter chains fail with most often and it is one of the least readable rustc emits. Here it is in full, from the lane behind the Run button:

error[E0308]: mismatched types
 --> /tmp/main.rs:2:5
  |
1 | fn total_chars(lines: &[&str]) -> usize {
  |                                   ----- expected `usize` because of return type
2 |     lines.iter().map(|l| l.len())
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `usize`, found `Map<Iter<'_, &str>, {closure@...}>`
  |
  = note: expected type `usize`
           found struct `Map<std::slice::Iter<'_, &str>, {closure@/tmp/main.rs:2:22: 2:25}>`

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0308`.

Read it in parts rather than as a wall, because each part has a different job. error[E0308] is a stable, searchable identifier — the same code means the same thing in every Rust program ever written, and rustc --explain E0308 prints its general write-up without you leaving the terminal. The --> line says where: file, line, column. The ----- underline marks the thing that set the expectation, and the label attached to it tells you which one — here expected usize because of return type, so the demand is coming from your signature, not from the chain. The ^^^^^ underline marks what actually turned up. The = note: lines then restate both types with nothing elided.

The specific difficulty of this message is the found type: Map<std::slice::Iter<'_, &str>, {closure@...}> is a name you did not write and cannot look up in your own code. The routine for that case is to read it as a record of what you built rather than as a type to hunt down. Read it outside-in: the outermost name is the last adapter you called (Map), its first parameter is what that adapter is wrapping (Iter, from slice::iter), and {closure@/tmp/main.rs:2:22: 2:25} is the anonymous type of the closure you passed, identified by exactly where you wrote it — line 2, columns 22 to 25. So the message is saying: you handed me the chain itself, not a result from running it. Every unconsumed chain fails this way, and the shape of the name tells you which adapters were on the end when you stopped.

Note what this diagnostic does not carry: there is no help: line offering a fix. rustc has no way to know whether you meant sum(), count(), collect() or last(), so it names the mismatch and stops. That is the normal state for this error class, and it is why the reading matters — the message rules out a whole family of causes (nothing is wrong with the closure, the borrow or the element type) and leaves you one question: which consuming adapter did you mean to put on the end?

Same Pipeline, Another Language

Almost every language now offers map, filter and a reduce, and the surface similarity to Rust is close enough to be misleading. One structural property does not survive the crossing:

Transfer

JavaScript arrays carry map, filter and reduce, and a chain of them reads almost exactly like the Rust chain you just assembled. Both express a pipeline of transformations as a sequence of small named steps. Which statement names what the two genuinely share, rather than a resemblance that stops at the syntax?

// The Rust half, complete and runnable. The JavaScript it is being compared
// with is the same four stages in the same order:
//     raw.map(s => Number(s))
//        .filter(n => !Number.isNaN(n) && n > 9)
//        .map(n => n * 2)
//        .reduce((a, b) => a + b, 0)
fn main() {
  let raw = vec!["12", "x", "30", "8"];

  let total: i32 = raw
      .iter()
      .filter_map(|s| s.parse::<i32>().ok())
      .filter(|n| *n > 9)
      .map(|n| n * 2)
      .sum();

  println!("total = {}", total);
}
Continue learning

Capstone Milestone

Capstone milestone

The capstone's taskwork reads a file of tasks and prints selected views of them: only the tasks at a chosen priority, a count of those, a count of the whole list. Every one of those views is an adapter chain over the same borrowed slice — which is why this lesson feeds the capstone more than any other. Take the three-function shape you just built and write the two chains taskwork needs most: one that selects a subset by a field, and one that reduces the whole set to a single number.

Hint: Write the selection one first and call it twice from main on the same slice — if the second call does not compile, an into_iter has slipped in where iter belonged, which is the failure the retrieval block above is about.

  • A selection function that takes a borrowed slice and returns a Vec of the elements matching a condition, using filter and collect, with the input still usable afterwards.
  • A counting or totalling function over the same slice that ends in a consuming adapter rather than a manual accumulator.
  • Both start from iter() rather than into_iter(), so the caller keeps ownership and can call them repeatedly on the same data.
  • Neither function indexes the slice or maintains a loop counter by hand.
Continue learning

Key Takeaways

  • Iterators implement the Iterator trait with a single next() method that returns Option<Item>
  • Use .iter() for borrows, .iter_mut() for mutable borrows, and .into_iter() to consume a collection
  • Iterator adapters like map, filter, enumerate, and zip are lazy — they do no work until consumed
  • Consuming adapters like collect, sum, fold, and any drive the iteration to completion
  • Implementing Iterator on your own type — just next() — is enough to get every adapter method, because they are provided methods on the trait rather than code you write
  • Iterator chains are designed to optimize away: with optimizations on they compile to the same machine code as a manual loop. Without optimizations, the adapter calls remain real function calls and a manual loop can be faster.
  • Prefer iterator chains over manual loops when the intent is clearer and the logic is functional in nature

Pro Tip: When you need both the index and the value in a loop, reach for .enumerate() instead of maintaining a manual counter. And when your iterator chain gets hard to read, consider breaking it into named intermediate variables — Rust's type inference handles the types, so you only need to name the values.

Next Steps

Now that you're comfortable with iterators, we'll explore strings and text processing — a topic where Rust's ownership model has some unique implications.

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.