Why I Reach for Rust Iterator Chains Instead of Index Loops
Rust iterator chains replace index bookkeeping with lazy, composable adapters. A worked filter/map/sum example, the laziness gotcha, borrowing choices, and when a plain loop is still the right call.
03 Jan 2026, 19:37 UTC

You need the total cost of all paid orders, with tax applied. The C-style instinct is an index loop: for i in 0..orders.len(), a mutable accumulator, and careful bookkeeping. In Rust, there's a better default — a lazy iterator chain — and choosing it is less about style than about deleting an entire class of bugs.
The thesis: for data transformations, iterator adapters express what you want (filter, transform, aggregate) while the compiler handles how to walk the data. Manual loops make you re-derive that machinery every time, and every re-derivation is a chance for an off-by-one error.
One trait, two kinds of methods
Everything hangs off std::iter::Iterator, which requires a single method: next(), returning Option<Item>. Every other method on the trait has a default implementation built on next(), and they split into two camps:
- Adapters —
map,filter,take,enumerate,zip— wrap an iterator in a new iterator type. They do no work when called. - Consumers —
sum,collect,fold,for_each— actually pull items through by callingnext()until it's exhausted.
A for loop is also a consumer: it desugars into into_iter() plus repeated next() calls.
The laziness gotcha that bites everyone once
Because adapters are lazy, this compiles but does nothing:
let doubled = numbers.iter().map(|n| n * 2); // no iteration happens hereRust warns you: adapters are marked #[must_use], so an unconsumed chain produces a warning along the lines of "unused Map that must be used" (exact wording varies by toolchain). Nothing is computed until you add a consumer — doubled.sum(), doubled.collect::<Vec<_>>(), or a for loop over it. Laziness is a feature: it means a chain of five adapters still walks the data once, fusing the steps per element, rather than building four intermediate collections.
A worked example, side by side
Here's the order-total problem both ways. Assume Order { is_paid: bool, amount: f64 } and orders: Vec<Order>:
// Manual loop
let mut total = 0.0;
for i in 0..orders.len() {
if orders[i].is_paid {
total += orders[i].amount * TAX_RATE;
}
}
// Iterator chain
let total: f64 = orders
.iter()
.filter(|o| o.is_paid)
.map(|o| o.amount * TAX_RATE)
.sum();The chain has no mutable accumulator, no index, and no way to read past the end of the vector. The intent — paid orders, taxed, summed — reads top to bottom. Clippy agrees: its needless_range_loop lint flags index loops that could be iterator loops, though lint behavior varies between toolchain releases, so check it on your own version with cargo clippy.
Run this with cargo run on current stable Rust to confirm it compiles and produces the same total as the loop. To see the laziness warning yourself, bind the chain without .sum() and rebuild.
Choosing iter, iter_mut, or into_iter
Part of the decision is which entry point to use, and picking wrong is a classic beginner compile error:
orders.iter()yields&Order— read-only borrow, collection still usable afterward.orders.iter_mut()yields&mut Order— for in-place updates.orders.into_iter()consumes the vector and yields ownedOrdervalues.
Note that for o in &orders desugars to into_iter() on the reference, which is why it borrows rather than moves. One edition-sensitive wrinkle: for arrays, arr.into_iter() yielded references before the 2021 edition and yields values from 2021 onward — check your Cargo.toml edition before reasoning about array code. Since Rust 1.26 you can also return impl Iterator<Item = T> from a function, handing callers a lazy pipeline without naming the concrete adapter type.
Performance: usually free, not guaranteed
Adapters are generic, so each chain is monomorphized — specialized into concrete code for your exact types — and LLVM commonly optimizes the result to machine code comparable to a hand-written loop, often eliding bounds checks on slice iteration. Treat "zero-cost" as typical behavior, not a promise: outcomes depend on rustc version, optimization flags, and code shape. If the claim matters for your workload, benchmark both versions with a harness like criterion on representative hardware, or compare assembly on Compiler Explorer with identical flags. Don't trust remembered numbers — including mine.
When a plain loop wins
Iterator chains aren't universally better. Logic that needs break with side effects, early return from the enclosing function, or index-dependent access ("compare each element with its neighbor") is often clearer as a loop. Long closure-heavy chains are also harder to step through in a debugger, and readability depends on your team's Rust fluency. The decision rule I use: if the operation is a pipeline — select, transform, aggregate — write the chain. If it's a procedure with control flow, write the loop.
Start by converting one index loop in your codebase, run cargo clippy to find others, and verify behavior with your existing tests. The compiler will keep you honest — that's the point.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.