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 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, yieldingT
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);
}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 — Transform Each Element
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
let doubled: Vec<i32> = numbers
.iter()
.map(|&n| n * 2)
.collect();
println!("Original: {:?}", numbers);
println!("Doubled: {:?}", doubled);
// Chain multiple adapters
let squared_evens: Vec<i32> = numbers
.iter()
.filter(|&&n| n % 2 == 0)
.map(|&n| n * n)
.collect();
println!("Squared evens: {:?}", squared_evens);
}
filter — Keep Only Matching Elements
filter takes a closure that returns true to keep an element or false to skip it.
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]
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:
| Method | Purpose |
|---|---|
.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);
// All iterator methods work for free!
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?
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
Long chains are usually written as one expression, but they can always be split into named steps — and splitting them is the best way to see that each adapter is just a value feeding the next. The lines below build a chain one step at a time, and every line uses the name the line before it created, so there is exactly one order that compiles.
Arrange the code
These six lines parse a list of strings, throw away anything that is not a number, keep the values above 9, double them, and print the total. Each line consumes the name the previous line bound. Put them in the only order that compiles.
let raw = vec!["12", "x", "30", "8"];let total: i32 = doubled.sum();let parsed = raw.iter().filter_map(|s| s.parse::<i32>().ok());println!("total = {}", total);let doubled = big.map(|n| n * 2);let big = parsed.filter(|n| *n > 9);
Written as one expression that chain would be raw.iter().filter_map(...).filter(...).map(...).sum(), and it would do exactly the same work in exactly the same order. Naming the intermediate steps changes nothing about the execution — it only makes the types visible, which is worth doing whenever a chain stops being readable. The total is 84: "x" fails to parse and is dropped by filter_map, 8 is filtered out for being under 10, and the surviving 12 and 30 double to 24 and 60.
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
Once it passes, try two variations and predict each before running:
- Swap the order of
filterandmap. Inshout_errors, move.map(|l| l.to_uppercase())in front of.filter(...), keeping the teststarts_with("ERROR"). Predict what comes back before running. The check still passes, because uppercasing does not change whether a line starts withERROR— but the closure types shift (filternow sees a&Stringrather 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. - Drop the consuming adapter. In
total_chars, delete.sum()and try to returnlines.iter().map(|l| l.len()). Decide what the compiler will say before running. It does not compile: the function promises ausizebut the expression is aMapiterator, 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.
Key Takeaways
- Iterators implement the
Iteratortrait with a singlenext()method that returnsOption<Item> - Use
.iter()for borrows,.iter_mut()for mutable borrows, and.into_iter()to consume a collection - Iterator adapters like
map,filter,enumerate, andzipare lazy — they do no work until consumed - Consuming adapters like
collect,sum,fold, andanydrive the iteration to completion - Implementing
Iteratoron your own types gives you all adapter methods for free - 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.
Next lesson
Strings and Text
Understand Rust's two string types, text manipulation, UTF-8 encoding, and common string operations
25 min