The Rust Engineering Handbook / Chapter 23
Iterator Architecture and Lazy Pipelines
Reason about iterator ownership, pull-based laziness, short-circuiting, custom contracts, allocation boundaries, and operational debugging.
Trace one request backward
Read this expression from the terminal operation, not from the source:
let first_large = Amounts::new(b"5, 25, 100")
.find_map(|amount| amount.ok().filter(|value| *value > 20));
find_map asks its input for one item. Amounts::next isolates and parses only 5; the closure rejects it. The consumer asks again, 25 flows through, and evaluation stops. The bytes for 100 are never parsed. No intermediate vector of fields or amounts exists.
That trace contains the iterator model: a stateful producer exposes one operation, adapters wrap producers, and a consumer drives the chain. The expression looks declarative, but its ownership and control flow are precise.
An iterator owns or borrows traversal state and yields one Item per call to next. Adapters remain lazy until a consumer pulls; the chosen item type determines ownership and aliasing, while the terminal operation determines stopping and materialization. Keep that division of responsibility in view through the rest of the chapter.

Iterator is a small protocol with large consequences
The required interface is an associated Item and fn next(&mut self) -> Option<Self::Item>. The mutable receiver means traversal state changes even when items are shared references. Some(item) advances successfully; None means this call produced no further item.
The standard contract deliberately permits an iterator to return Some after a None unless it promises FusedIterator. Most ordinary collections are fused, but generic code must not invent that guarantee. Calling .fuse() wraps a producer so exhaustion becomes permanent.
Many methods consume self. This does not imply they consume the underlying collection: an iterator value may merely contain references. It means the adapter or consumer takes ownership of that traversal state. Methods such as find, position, and try_fold instead borrow &mut self, allowing a caller to inspect what remains afterward.
size_hint reports a lower and optional upper bound on remaining items. It is useful for capacity planning, not a general correctness proof. ExactSizeIterator promises an exact remaining length and exposes len, but it is a safe trait: unsafe code may not assume a dishonest implementation is impossible. Filtering usually destroys exactness because the number of accepted items is unknown even if the input length is exact.
IntoIterator chooses how traversal begins
Iterator describes an active traversal. IntoIterator converts a value into one and is what a for loop uses. For collection-like types, three implementations often form an ownership matrix:
| Expression | Typical item | Collection afterward | Primary use |
|---|---|---|---|
collection.iter() or (&collection).into_iter() |
&T |
available | inspect |
collection.iter_mut() or (&mut collection).into_iter() |
&mut T |
available after traversal | update in place |
collection.into_iter() |
T |
moved | transfer or transform ownership |
These are API contracts, not universal syntax rules. Inspect the actual IntoIterator implementations for a generic type. Arrays, maps, custom collections, references, and smart pointers may yield different item forms.
Prefer an IntoIterator parameter when a function needs one traversal and does not care whether the caller starts from a collection, range, or existing iterator:
fn sum_amounts<I>(values: I) -> u64
where
I: IntoIterator<Item = u64>,
{
values.into_iter().sum()
}
Require Iterator itself when the function must use current traversal state or return the remainder. Requiring Clone, DoubleEndedIterator, or ExactSizeIterator should follow an algorithmic need, not a desire for convenience.
Adapters build types; consumers do work
map, filter, enumerate, zip, take, scan, and flat_map return new iterator values. Creating them normally performs no iteration. Each concrete adapter stores its upstream iterator and any closure environment. Long chains therefore create nested types but not necessarily heap allocations.
Consumers include sum, fold, count, collect, for_each, and comparison methods. Some consume the full stream; others short-circuit. any, all, find, find_map, position, try_fold, and collection into Result<_, _> can stop early. That distinction is operationally important for input validation, I/O wrappers, rate limits, and infinite iterators.
Laziness does not mean memoization or parallelism. Rebuilding or cloning an iterator can repeat work. A closure may perform logging, mutation, or I/O each time it is invoked. Infinite producers require bounded consumers such as take; a full collect or count never completes.
Adapter order changes cost and sometimes semantics. Filter cheap rejects before an expensive map when equivalent. Do not reorder fallible parsing past validation if doing so changes which error is observed. A pipeline is control flow: review it with the same care as loops and early returns.
The target decides what collect materializes
collect is generic over FromIterator; the target type decides what is built. Vec<T> commonly allocates, a String builds owned text, HashMap<K, V> builds a table, and Result<C, E> can stop on the first error while collecting successful items into C. Type annotations or turbofish syntax identify the target when inference cannot.
Materialization is appropriate when later stages need random access, repeated passes, sorting, stable storage, or a lifetime independent of the source. It is wasteful when a single fold or short-circuiting query answers the question. Conversely, refusing all allocation can produce opaque lifetime coupling or repeated expensive work. Put the boundary where ownership and access patterns change.
FromIterator creates a new value from items; Extend adds items to an existing value. A custom collection should make duplicate handling, failure behavior, order, and capacity strategy clear. Because FromIterator::from_iter is infallible at the trait level, use items such as Result<T, E>, a validated constructor, or a named fallible method when construction can reject the sequence.
Implement a zero-allocation parser pipeline
The chapter fixture’s Amounts<'a> stores only the unconsumed byte slice. next finds one comma, trims the borrowed field, and folds ASCII digits with checked arithmetic:
pub struct Amounts<'a> {
remaining: &'a [u8],
}
impl<'a> Amounts<'a> {
pub const fn new(input: &'a [u8]) -> Self {
Self { remaining: input }
}
}
Its executable Iterator implementation in trait-system-lab splits one borrowed field, advances remaining, and parses with checked arithmetic. The state borrows the input, so yielded numbers can outlive individual fields but the traversal cannot outlive the byte buffer. There is no per-field String and no intermediate Vec. Invalid syntax is an item-level Err, not exhaustion; callers may stop at the first error by collecting into Result<Vec<_>, _>, or deliberately continue and report multiple errors.
The implementation marks FusedIterator because once remaining is empty every later call returns None. It does not claim ExactSizeIterator because it neither stores nor computes the exact number of fields still to be yielded. A parse error is still one yielded item, so validity does not alter that count.
Zero allocation is a scoped claim. The iterator itself and parsing path do not allocate; a caller that collects into a Vec does. Validate such claims with an allocator instrument, profile, or benchmark when they matter in production rather than inferring machine behavior from surface syntax.
Custom iterator review begins with state transitions
Before implementing Iterator, write the state machine independently of adapter syntax. For Amounts, the state is the unconsumed byte suffix. Every call must either return None from an empty suffix or consume exactly one field, including an invalid field. That progress rule prevents an error from repeating forever. Checked multiplication ensures that overflow is data failure rather than build-profile-dependent arithmetic.
Test the protocol, not only happy-path output: empty and single-field input; adjacent and trailing delimiters; every invalid byte class; the largest accepted value and overflow beyond it; partial consumption through by_ref; and repeated calls after exhaustion. If next can panic after advancing only part of its state, document whether catching the panic and continuing is supported. Safe Rust does not promise logical recoverability after a caught panic.
For resource-backed iterators, specify what drop closes, unlocks, acknowledges, or abandons. An iterator over database pages or directory entries also has latency, retry, consistency, and partial-result contracts even though its surface still says Option<Item>.
Iterator<Item = Result<T, E>> makes failure one element in a potentially recoverable stream and permits fail-fast or error accumulation. A custom pull method returning Result<Option<T>, E> makes traversal itself fallible but does not implement standard Iterator. Choose based on whether an error leaves traversal state valid.
Borrowing iterators encode lifetime in Item
An iterator may yield references tied to its source. A slice iterator yields &'a T; a mutable slice iterator yields &'a mut T one disjoint element at a time. Custom iterators over internal data must ensure references remain valid and exclusive for the promised lifetime.
The standard Iterator trait cannot express every “item borrows from this particular call to next” pattern because Item is one associated type independent of the method borrow. APIs for lending views may use callback-based access, indices, dedicated methods, or more advanced trait designs. Do not force such data into Iterator with unsafe lifetime extension.
Holding a yielded shared reference may keep the source borrowed and block mutation. Holding yielded mutable references extends exclusivity. The correct repair is often to shorten collection scope, process in place, split data, or yield stable identifiers—not to clone everything reflexively.
Readability and debugging are design constraints
A six-stage chain can reveal dataflow better than six mutable temporaries. A fifteen-stage chain with fallible closures, stateful scans, and side effects can conceal it. Name domain transformations with functions; extract a custom adapter or explicit loop when state transitions matter; keep error context near the operation that can fail.
inspect is useful for temporary observation, but it runs only when the chain is consumed and only for pulled items. Logging inside a lazy adapter can disappear after a refactor that stops earlier. Avoid treating inspect as durable audit logging. For operational metrics, define which terminal outcomes own emission and test short-circuit paths.
Consider an incident in which a reconciliation metric drops after collect is replaced with find. The business result is correct, but the metric lived in inspect and accidentally measured pulled candidates rather than available records. The repair is not forced full traversal. Emit the decision metric at the terminal boundary and source-volume metrics where the source is known.
Debug type-heavy chains by naming semantic stages, extracting closure bodies into typed functions, and asserting the first few next transitions. Avoid boxing solely to simplify the displayed type: Box<dyn Iterator<Item = T>> adds erasure, indirection, lifetime constraints, and often allocation. Return impl Iterator for one opaque concrete chain, use an enum for a small closed set, and box only for real runtime heterogeneity.
Production cost belongs to the terminal workload
Iterator cost depends on item representation, closure capture, inlining, bounds checks, vectorization, code size, branches, and the consumer. A chain over slices may fuse into one loop; a dynamic boundary may prevent inlining; collect may reserve from a useful size hint. None is guaranteed merely by iterator syntax.
Benchmark a clear alternative under identical validation and error semantics. Use release builds and representative distributions. Inspect profiles or assembly only after a measured difference justifies it. If a direct loop is faster and clearer on a critical path, use it; iterators are an abstraction tool, not a loyalty test.
For attacker-controlled inputs, laziness may reduce work through short-circuiting but can also permit unbounded consumption. Apply byte, item, time, and nesting limits. take(limit) silently truncates unless the API checks for an extra item; security-sensitive parsers must distinguish “complete within limit” from “prefix accepted.”
Compare three implementations before optimizing:
- A direct loop makes mutation, stopping, and error context explicit.
- An iterator pipeline makes transformations composable and often optimizes well.
- Materializing stages simplifies repeated access but adds ownership and allocation.
Measure generated behavior for the real workload. Iterator abstraction is designed to optimize away in many cases, but “zero cost” is neither a language-level timing guarantee nor permission to skip profiling.
Failure modes that compile
- Calling
mapand forgetting a consumer, so no work occurs. - Using
filter_map(Result::ok)and silently discarding corrupt input. - Collecting between every stage and hiding repeated allocations.
- Choosing
into_iterwhen later code still needs the collection. - Cloning items to escape a borrow without deciding who should own them.
- Implementing
FusedIteratororExactSizeIteratorwithout maintaining its semantic promise. - Trusting
size_hintfor unsafe writes. - Placing side effects in adapters whose execution count changes with short-circuiting.
- Running an unbounded consumer on an infinite or externally controlled stream.
- Compressing a state machine into a clever
scanthat reviewers cannot audit.
Exercise: parser, three consumers, one allocation ledger
Extend Amounts with line and byte-offset error context without allocating field strings. Then implement:
- a first-error collector returning
Result<Vec<u64>, ParseAmountError>; - a short-circuiting search for the first amount above a limit;
- an audit pass that counts all invalid fields without retaining them.
Record every ownership transfer and allocation boundary. Add exhaustion tests proving the fused promise, overflow tests, empty-field tests, and a property that successful parsing agrees with a simple reference implementation. Compare the iterator with an explicit loop for readability and measured throughput. Do not declare victory from instruction count alone; state input sizes, compiler, target, build profile, and allocator method.
Review checklist
- What owns traversal state, and what does each
Itemown or borrow? - Does the API want
IntoIteratoror an already-startedIterator? - Which operation drives evaluation, and can it short-circuit?
- Are adapter side effects observable under partial consumption?
- Where are allocation and ownership deliberately materialized?
- Are
size_hint, exact-size, double-ended, and fused claims correct? - Can input be infinite or attacker-controlled, and what bounds work?
- Would a named function or loop expose failures more clearly?
- Which claims are semantic guarantees and which require profiling?
Durable takeaways
Iterators join ownership with control flow. IntoIterator selects a traversal and item mode; Iterator::next advances state; adapters compose lazily; consumers decide work, stopping, and collection. Once those roles are separated, iterator code becomes reviewable architecture rather than fluent syntax.
The next chapter broadens that API-design question. Iterator inputs and collection outputs are two members of a larger family of conversion and view traits, each promising a different ownership, cost, and equivalence relationship.
Sources and version notes
- Standard library:
Iterator,IntoIterator, and thestd::itermodule - Standard library:
ExactSizeIteratorandFusedIterator - Standard library:
FromIteratorandExtend - The parser, ownership modes, short-circuiting, and fused-exhaustion tests were checked in the dependency-free fixture with Rust 1.97.0 and the declared Rust 1.85.0 MSRV on
x86_64-unknown-linux-gnu. Optimization and allocation behavior beyond the explicit data structures remain implementation and workload observations.
Continue reading
Full table of contents