Rust Iterators: laziness, consumption, fold, and collect

Author

Andres Monge

Published

June 5, 2026

Rust iterators are easiest to understand if you split methods into two groups:

That distinction explains laziness, single-pass behavior, and many common bugs.

Adapters vs Consumers

These are adapters:

let iter = [1, 2, 3, 4]
    .iter()
    .map(|n| n * 2)
    .filter(|n| *n > 4);

At this point, no numbers have been collected or printed.

Rust has only built a lazy pipeline.

These are consumers:

let total: i32 = [1, 2, 3, 4].iter().copied().sum();

let values: Vec<i32> = [1, 2, 3, 4].iter().map(|n| n * 2).collect();

let product = [1, 2, 3, 4].iter().fold(1, |acc, n| acc * *n);

Methods like sum(), collect(), fold(), count(), find(), and for_each() consume the iterator.

Laziness Means “Describe First, Run Later”

This is lazy:

let pipeline = ["10", "20", "30"]
    .iter()
    .map(|text| text.parse::<i32>().unwrap())
    .filter(|n| *n >= 20);

The parsing and filtering happen only when some consumer asks for values.

let numbers: Vec<i32> = pipeline.collect();

That is when the work happens.

Iterators Are Usually Single-Pass

An iterator is not a reusable container. It is a walk through data.

Once items have been pulled out, they are gone from that iterator.

let mut iter = [10, 20, 30].into_iter();

assert_eq!(iter.next(), Some(10));
assert_eq!(iter.next(), Some(20));
assert_eq!(iter.next(), Some(30));
assert_eq!(iter.next(), None);

That same idea causes real bugs with split() iterators.

The Exhausted split('+') Bug

This looks reasonable at first glance:

let expr = "10+20+30";
let mut parts = expr.split('+');

let valid = parts.all(|part| !part.trim().is_empty());
let total: i32 = parts
    .map(|part| part.trim().parse::<i32>().unwrap())
    .sum();

println!("valid = {valid}, total = {total}");

But all() is a consumer.

It already walked the iterator.

So by the time sum() runs, parts is exhausted and total becomes 0.

The bug is not in split() itself. The bug is assuming an iterator can be reused like a stored list.

The same idea appears in more realistic code with try_fold():

let mut left_split = left.split('+');

let left_sum: Result<usize, MyError> =
    left_split.try_fold(0, |acc, word| Ok(acc + word_to_int(map, word)?));

let left_no_leading_zero =
    left_split.all(|word| !is_leading_zero(map, word));

The problem is the same.

left_split is one iterator value.

After try_fold(...), that iterator has already been advanced to the end.

So when all(...) runs:

  • there are no words left
  • the closure is never called
  • is_leading_zero(map, word) never runs
  • all(...) returns true

That last point surprises many people at first.

all(...) returns true on an empty iterator because there was no item that violated the predicate.

That is also why a dbg!() inside the closure never prints. The closure is not “failing to print.” It is never entered.

One fix is to split twice when each pass is cheap and clear:

fn is_valid(map: &HashMap<char, u8>, left: &str, right: &str) -> Result<bool, MyError> {
    let left_sum: Result<usize, MyError> =
        left.split('+')
            .try_fold(0, |acc, word| Ok(acc + word_to_int(map, word)?));
    let left_no_leading_zero =
        left.split('+').all(|word| !is_leading_zero(map, word));

    let right_sum: Result<usize, MyError> =
        right.split('+')
            .try_fold(0, |acc, word| Ok(acc + word_to_int(map, word)?));
    let right_no_leading_zero =
        right.split('+').all(|word| !is_leading_zero(map, word));

    Ok(left_no_leading_zero && right_no_leading_zero && left_sum? == right_sum?)
}

Another fix is to collect once if you need multiple passes over the same pieces:

fn is_valid(map: &HashMap<char, u8>, left: &str, right: &str) -> Result<bool, MyError> {
    let left_words: Vec<&str> = left.split('+').collect();
    let right_words: Vec<&str> = right.split('+').collect();

    let left_sum = left_words
        .iter()
        .try_fold(0usize, |acc, word| Ok(acc + word_to_int(map, word)?));
    let right_sum = right_words
        .iter()
        .try_fold(0usize, |acc, word| Ok(acc + word_to_int(map, word)?));

    let left_no_leading_zero = left_words.iter().all(|word| !is_leading_zero(map, word));
    let right_no_leading_zero = right_words.iter().all(|word| !is_leading_zero(map, word));

    Ok(left_no_leading_zero && right_no_leading_zero && left_sum? == right_sum?)
}

Now the reusable container is Vec<&str>, not the iterator.

sum() Is Great for “Add Everything”

Use sum() when the intent is just accumulation.

let total: i32 = [1, 2, 3, 4].iter().copied().sum();

The type annotation matters.

Without enough type information, Rust may not know what numeric type you want.

That is why these are common:

let total: i64 = [1, 2, 3, 4].iter().copied().sum();

let total = [1_i64, 2, 3, 4].iter().copied().sum::<i64>();

collect() Needs a Destination Type

collect() is flexible, which means Rust often needs help deciding what to build.

let words: Vec<&str> = "red,green,blue".split(',').collect();

Or:

let words = "red,green,blue"
    .split(',')
    .collect::<Vec<&str>>();

Same pipeline, same values, different style.

The important idea is that collect() consumes the iterator and builds some collection type.

fold() Lets You Control the Accumulator

fold() is the most general “reduce this iterator into one value” tool.

let total = [1, 2, 3, 4]
    .iter()
    .fold(0, |acc, n| acc + *n);

The accumulator type starts from the initial value.

Here, 0 makes acc an integer accumulator.

You can make that more explicit when needed:

let total = [1, 2, 3, 4]
    .iter()
    .fold(0_i64, |acc, n| acc + i64::from(*n));

Now the accumulator is clearly i64.

You can also fold into non-numeric values:

let csv = ["red", "green", "blue"].iter().fold(
    String::new(),
    |mut acc, word| {
        if !acc.is_empty() {
            acc.push(',');
        }
        acc.push_str(word);
        acc
    },
);

try_fold() Stops Early on Failure

Use try_fold() when each step may fail and you want to stop at the first error.

fn sum_csv_numbers(text: &str) -> Result<i32, std::num::ParseIntError> {
    text.split(',').try_fold(0, |acc, piece| {
        let n = piece.trim().parse::<i32>()?;
        Ok(acc + n)
    })
}

Type flow:

  1. split(',') yields &str
  2. parse::<i32>() yields Result<i32, ParseIntError>
  3. ? returns early from the try_fold closure on error
  4. try_fold(...) returns Result<i32, ParseIntError>

This is a good example of iterator pipelines and ? working together.

fold() vs sum()

If you only want addition, prefer sum() because it says exactly what you mean.

let total: i32 = [1, 2, 3, 4].iter().copied().sum();

If you want custom accumulation logic, use fold().

let weighted = [10, 20, 30]
    .iter()
    .enumerate()
    .fold(0, |acc, (ix, n)| acc + (ix as i32 * *n));

If you want custom accumulation that can fail, use try_fold().

A Useful Mental Model

Read iterator chains in two passes.

First ask:

What values would this pipeline produce if consumed?

Then ask:

What method finally consumes it?

That keeps the roles clear:

  • map, filter, rev, enumerate, take: shape the walk
  • sum, collect, fold, try_fold, count: run the walk

TL;DR

  • iterators are lazy: adapters describe work, consumers perform it
  • most iterators are single-pass, so consumed items are gone
  • split('+') returns an iterator, not a reusable list
  • sum() is best for straightforward addition
  • collect() needs a destination type such as Vec<_>
  • fold() gives you full control over the accumulator and its type
  • try_fold() is the fallible version that can stop early with ?

If you want the matching article about Option, Result, ?, and left-to-right type flow, see option-result-question-mark-blocks-closures.qmd.