Rust Iterators vs Manual Loops: When Zero-Cost Abstractions Actually Cost Zero
Rust's Iterator adaptors often compile to the same machine code as hand-written loops—but only when the optimizer can prove bounds safety. Here's how to verify it, where it breaks, and when to choose each style.
08 Oct 2026, 03:37 UTC

The Problem: Trusting High-Level Code to Run Fast
You write data.iter().filter(|x| x > &0).map(|x| x * 2).sum() because it reads like the problem statement. A colleague writes a for loop with an index and a mutable accumulator. Which one compiles to faster machine code? In Rust, the answer is often "they're identical," but only when the optimizer can see through the abstraction layers. Understanding when that happens—and when it doesn't—lets you choose the style that matches your performance and maintenance goals.
How Iterator Chains Become Machine Code
The Iterator trait is lazy: calling map, filter, or fold builds a new iterator struct that wraps the previous one. No work happens until a consumer like collect(), for_each(), or sum() drives the chain. Each adaptor is a generic struct with an associated next() method. Because Rust monomorphizes generics, the compiler generates a distinct copy of next() for every concrete type in the chain. LLVM then inlines those tiny next() bodies, fusing the whole pipeline into a single loop body.
When the iterator's size_hint() returns an exact length and the element type is Copy or the loop only reads through shared references, LLVM can prove that every index access stays in bounds. It then elides the bounds checks that a naïve for i in 0..len { v[i] } loop would keep. The resulting assembly is a tight counted loop with a single induction variable—exactly what you'd write by hand in C.
Worked Example: Summing Positive Doubles
Consider this benchmark comparing three approaches on a Vec of 10,000 elements:
// Manual indexed loop
fn manual_sum(v: &[i32]) -> i32 {
let mut total = 0;
for i in 0..v.len() {
if v[i] > 0 {
total += v[i] * 2;
}
}
total
}
// Iterator chain
fn iter_sum(v: &[i32]) -> i32 {
v.iter()
.filter(|x| **x > 0)
.map(|x| x * 2)
.sum()
}
// Explicit fold (same semantics, different style)
fn fold_sum(v: &[i32]) -> i32 {
v.iter().fold(0, |acc, x| {
if *x > 0 { acc + x * 2 } else { acc }
})
}
Compile with rustc --release --emit=asm (or paste into Godbolt with the opt pipeline). In the .text section you'll see a single loop for all three functions: a cmp/jle guard, a multiply, an add, and an index increment. No calls to next(), no trait dispatch, no bounds-check branches. The filter and map adaptors have been inlined and fused.
Run a criterion microbenchmark on a recent x86-64 target (Rust 1.80+). Typical results show all three variants within 2–3% of each other, well inside measurement noise. The iterator version is not "fast enough"; it is literally the same instructions.
Where the Abstraction Leaks
Two practical costs appear when the optimizer's assumptions break:
- Compile-time bloat. Each adaptor layer adds a monomorphized
next()body. A chain of ten adaptors on five concrete types generates fifty tiny functions for LLVM to inline. On large codebases this can add seconds to clean builds. Mitigation: factor hot iterator chains into a dedicated function so monomorphization happens once, or use#[inline(never)]on cold paths to limit code growth. - Readability for unfamiliar readers. Developers coming from languages without lazy iterators may misread
.filter(...).map(...)as eager allocations. A comment like// lazy: no allocation until sum()helps. For complex logic with early exits or multiple accumulators, an explicitforloop with labeledbreakcan be clearer than nestingfoldclosures.
Bounds checks also return when the iterator length isn't statically known—e.g., vec.iter().skip(n).take(m) where n and m are runtime values. In that case the manual loop with an explicit range n..n+m may actually generate fewer checks because the programmer asserts the invariant once.
Verify It Yourself
- Write the two versions side by side in a
#[bench]function (orcriterionbenchmark). - Run
cargo bench -- --profile-time=5to get stable numbers. - Inspect
target/release/deps/your_crate-*.asmor usecargo rustc --release -- --emit=asmto confirm the loop bodies match. - If they diverge, check
size_hint()implementations and whether any adaptor returns a type that doesn't implementExactSizeIterator.
When the assembly matches, you've confirmed the zero-cost claim for that specific chain. When it doesn't, you have a concrete optimization target: either restructure the iterator chain or drop to a manual loop with a comment explaining why.
Closing Takeaway
Rust's iterator adaptors are zero-cost when the type system gives the optimizer enough information. In the common case—fixed-length slices, Copy elements, simple predicates—they compile to the same loop you'd write by hand, with the added safety of no manual indexing. The trade-offs are compile-time pressure and a learning curve for readers. Profile your hot paths, read the assembly once, and then choose the style that communicates intent most clearly for the surrounding codebase.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.