Iterator Chains in Rust: Power, Elegance, and Zero-Cost Abstraction
Iterators & Closures

Iterator Chains in Rust: Power, Elegance, and Zero-Cost Abstraction

A deep dive into Rust's iterator system — what it is, how the compiler thinks about it, and how to wield it effectively for composable, zero-cost data transformations.

By Luis SoaresMarch 10, 20269 min readOriginal on Medium

If you've been writing Rust for more than a week, you've almost certainly reached for .iter().map().filter().collect(). But do you actually know what's happening under the hood when you write that chain? And do you know how to use iterator adapters to write code that is not just readable, but genuinely composable and, in many cases, faster than the equivalent imperative loop?

This post is a deep dive into Rust's iterator system — what it is, how the compiler thinks about it, and how to wield it effectively.


The Iterator Trait: Where Everything Starts

At its core, Rust's entire iterator system is built on one remarkably minimal trait:

pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;

    // Hundreds of provided methods follow...
}

That's it. You define Item and implement next, and you get access to the entire standard library's iterator adapter machinery for free. Every map, filter, flat_map, take, skip, zip, chain, fold, and collect is a provided method defined in terms of next.

This design is not just elegant — it has profound performance implications, which we'll get to shortly.


What Is an Iterator Chain?

An iterator chain is a sequence of iterator adapters stacked on top of a source iterator. Each adapter wraps the previous one, forming a lazy pipeline.

let data = vec![1u32, 2, 3, 4, 5, 6, 7, 8, 9, 10];

let result: Vec<u32> = data
    .iter()
    .filter(|&&x| x % 2 == 0)   // keep even numbers
    .map(|&x| x * x)             // square them
    .take(3)                     // take the first 3
    .collect();

assert_eq!(result, vec![4, 16, 36]);

The key insight: nothing happens until collect() is called. The filter, map, and take calls each return a new struct that describes a transformation. Only when a terminal adapter (collect, for_each, fold, sum, any, all, etc.) drives the pipeline does any computation occur.


Under the Hood: Monomorphization and Zero-Cost Abstraction

Let's look at what the compiler actually generates. When you write:

fn process(v: &[u32]) -> Vec<u32> {
    v.iter()
        .filter(|&&x| x % 2 == 0)
        .map(|&x| x * x)
        .collect()
}

The type of the chain before collect is something like:

Map<Filter<std::slice::Iter<'_, u32>, [closure]>, [closure]>

Each adapter is a concrete struct. filter returns a Filter<I, P> where I is the upstream iterator and P is the predicate. map returns a Map<I, F>. These types are fully resolved at compile time through monomorphization — the compiler generates specialized machine code for this exact combination of types and closures.

Here's a simplified view of how Map is implemented internally:

pub struct Map<I, F> {
    iter: I,
    f: F,
}

impl<B, I: Iterator, F: FnMut(I::Item) -> B> Iterator for Map<I, F> {
    type Item = B;

    fn next(&mut self) -> Option<B> {
        self.iter.next().map(|x| (self.f)(x))
    }
}

Because F is a type parameter (not a trait object), the compiler can inline the closure body directly into next. Because next is called in a loop by collect, the optimizer can further inline the entire chain into a single tight loop — often producing code that is identical to what you'd write by hand.

This is what Rust means by zero-cost abstraction: you pay no runtime cost for the layering. The abstractions dissolve at compile time.


Building Your Own Iterator

Understanding the trait means you can build custom iterators that plug seamlessly into the rest of the ecosystem. Here's a classic example — a Chunks iterator that yields fixed-size windows of a slice as owned Vecs:

struct Chunks<'a, T> {
    slice: &'a [T],
    size: usize,
}

impl<'a, T> Chunks<'a, T> {
    fn new(slice: &'a [T], size: usize) -> Self {
        assert!(size > 0, "chunk size must be non-zero");
        Chunks { slice, size }
    }
}

impl<'a, T> Iterator for Chunks<'a, T> {
    type Item = &'a [T];

    fn next(&mut self) -> Option<Self::Item> {
        if self.slice.is_empty() {
            return None;
        }
        let end = self.size.min(self.slice.len());
        let chunk = &self.slice[..end];
        self.slice = &self.slice[end..];
        Some(chunk)
    }
}

// Usage — composes perfectly with the rest of the stdlib
fn main() {
    let data = vec![1, 2, 3, 4, 5, 6, 7];
    let sums: Vec<i32> = Chunks::new(&data, 3)
        .map(|chunk| chunk.iter().sum())
        .collect();

    println!("{:?}", sums); // [6, 15, 7]
}

Your custom type participates in the full iterator protocol. That means you can call .enumerate(), .zip(), .flat_map(), .peekable(), or anything else on it for free.


Composability in Practice

The real power of iterator chains is how naturally they compose across function boundaries. Consider a data-processing pipeline for a simplified order book scenario:

#[derive(Debug, Clone)]
struct Order {
    id: u64,
    price: f64,
    quantity: u64,
    side: Side,
}

#[derive(Debug, Clone, PartialEq)]
enum Side { Buy, Sell }

fn best_bids(orders: &[Order], top_n: usize) -> Vec<(u64, f64)> {
    let mut bids: Vec<&Order> = orders
        .iter()
        .filter(|o| o.side == Side::Buy)
        .collect();

    // Sort descending by price (best bid = highest price)
    bids.sort_unstable_by(|a, b| b.price.partial_cmp(&a.price).unwrap());

    bids.iter()
        .take(top_n)
        .map(|o| (o.id, o.price))
        .collect()
}

fn total_ask_volume(orders: &[Order], price_limit: f64) -> u64 {
    orders
        .iter()
        .filter(|o| o.side == Side::Sell && o.price <= price_limit)
        .map(|o| o.quantity)
        .sum()
}

Both functions are self-contained, readable, and compose cleanly. Neither one needs a loop variable, an index, or a mutable accumulator in the body — the iterator chain is the logic.


Flattening and flat_map

Practice what you learned

Reinforce this article with hands-on coding exercises and AI-powered feedback.

View all exercises

One of the most powerful adapters in the toolkit is flat_map. It maps each item to an iterator and then flattens the results, avoiding intermediate allocations.

// Parse a batch of CSV rows, skipping malformed ones
fn parse_values(rows: &[&str]) -> Vec<f64> {
    rows.iter()
        .flat_map(|row| {
            row.split(',')
                .map(|s| s.trim().parse::<f64>())
                .filter_map(|r| r.ok())
        })
        .collect()
}

fn main() {
    let rows = ["1.1, 2.2, bad, 3.3", "4.4, 5.5", "nope, 6.6"];
    let values = parse_values(&rows);
    println!("{:?}", values); // [1.1, 2.2, 3.3, 4.4, 5.5, 6.6]
}

flat_map with filter_map is a pattern that appears constantly in production Rust — parsing, transforming, and filtering in a single expressive pass.


scan: Stateful Iteration

When you need to carry state through a pipeline without resorting to imperative code, scan is your friend. It's like fold, but yields each intermediate accumulator value:

fn running_pnl(trades: &[(f64, i64)]) -> Vec<f64> {
    // Each trade is (price, signed_quantity): positive = buy, negative = sell
    trades
        .iter()
        .scan(0.0_f64, |cumulative, &(price, qty)| {
            *cumulative += price * qty as f64;
            Some(*cumulative)
        })
        .collect()
}

fn main() {
    let trades = [(100.0, 10), (102.0, -5), (98.0, 3)];
    let pnl = running_pnl(&trades);
    println!("{:?}", pnl); // [1000.0, 490.0, 784.0]
}

chain and zip: Combining Iterators

Two underused adapters that unlock powerful compositional patterns:

chain concatenates two iterators of the same type:

fn all_active_ids(primary: &[u64], fallback: &[u64], limit: usize) -> Vec<u64> {
    primary.iter()
        .chain(fallback.iter())  // transparently walk both
        .copied()
        .take(limit)
        .collect()
}

zip pairs two iterators together, stopping when either is exhausted:

fn weighted_average(values: &[f64], weights: &[f64]) -> f64 {
    let total_weight: f64 = weights.iter().sum();
    values.iter()
        .zip(weights.iter())
        .map(|(v, w)| v * w)
        .sum::<f64>()
        / total_weight
}

Both adapters are lazy and zero-cost. They fuse directly into whatever terminal drives the chain.


Iterator Fusion and for_each

A subtle but important optimization: Rust's iterator chains often benefit from fusion — the LLVM optimizer combining multiple passes into one. To maximize this, prefer terminal adapters over explicit for loops when side-effects are involved:

// Prefer this
prices.iter()
    .filter(|&&p| p > threshold)
    .enumerate()
    .for_each(|(i, &p)| println!("{i}: {p:.2}"));

// Over this
for (i, p) in prices.iter().enumerate() {
    if *p > threshold {
        println!("{i}: {p:.2}");
    }
}

Both produce equivalent machine code, but the chain version makes the intent explicit: filter first, then enumerate, then act. The imperative version requires the reader to reconstruct that intent from the condition and body.


A Real-World Pipeline: Processing Market Data

Here's a more complete example that ties several techniques together — simulating a lightweight market data processor:

use std::collections::HashMap;

#[derive(Debug)]
struct Tick {
    symbol: String,
    price: f64,
    volume: u64,
    timestamp_ms: u64,
}

fn vwap_by_symbol(ticks: &[Tick]) -> HashMap<String, f64> {
    // Group ticks by symbol, compute VWAP for each
    let mut groups: HashMap<&str, (f64, u64)> = HashMap::new();

    ticks
        .iter()
        .filter(|t| t.volume > 0)
        .for_each(|t| {
            let entry = groups.entry(&t.symbol).or_insert((0.0, 0));
            entry.0 += t.price * t.volume as f64;
            entry.1 += t.volume;
        });

    groups
        .iter()
        .map(|(&sym, &(pv, vol))| {
            (sym.to_string(), pv / vol as f64)
        })
        .collect()
}

fn top_volume_symbols(ticks: &[Tick], n: usize) -> Vec<String> {
    let mut volume_by_symbol: HashMap<&str, u64> = HashMap::new();

    ticks.iter().for_each(|t| {
        *volume_by_symbol.entry(&t.symbol).or_insert(0) += t.volume;
    });

    let mut pairs: Vec<_> = volume_by_symbol.into_iter().collect();
    pairs.sort_unstable_by(|a, b| b.1.cmp(&a.1));

    pairs.into_iter()
        .take(n)
        .map(|(sym, _)| sym.to_string())
        .collect()
}

Each function is a focused, composable unit. No shared mutable state. No index arithmetic. The intent maps directly to the code structure.


When Not to Use Iterator Chains

Iterator chains are not always the right tool:

  • Early-exit with complex state: Sometimes a loop with break and a local accumulator is clearer than a convoluted scan or try_fold.
  • Parallel iteration with rayon: Rayon's parallel iterator API is a drop-in for many chains, but the conversion is not always trivial. Design for it if throughput matters.
  • Nested mutable borrows: The borrow checker can make some patterns difficult to express as chains. Prefer clarity over cleverness.

The heuristic: if you need more than one or_else, three levels of nesting, or a closure that captures mutable state in surprising ways — step back and write a plain loop. Iterator chains shine when they reduce the number of moving parts, not when they multiply them.


Summary

Rust's iterator system is one of its most carefully designed features. The key ideas to take away:

  • Everything builds on next: one method gives you the entire adapter ecosystem.
  • Chains are lazy: no work happens until a terminal adapter drives them.
  • Zero-cost abstraction is real: the compiler monomorphizes and inlines the chain into a single loop — no virtual dispatch, no heap allocation.
  • Composability is the payoff: small, focused transformations stack cleanly, and your own types integrate seamlessly by implementing Iterator.
  • Custom iterators are first-class: Chunks, Windows, graph traversals, event streams — if it has a meaningful "next element," model it as an iterator.

Writing idiomatic Rust isn't about avoiding loops. It's about choosing the right level of abstraction for what you're expressing. Iterator chains sit at exactly the right level for the vast majority of data transformation code — expressive enough to read like a specification, and efficient enough to run like handwritten assembly.

Practice what you learned

Reinforce this article with hands-on coding exercises and AI-powered feedback.

View all exercises

Related Articles

Master Iterators & Closures hands-on

Go beyond reading — solve interactive exercises with AI-powered code review, track your progress, and get a Skill Radar assessment.