Crabrix blog
Rust iterators: clear pipelines without extra allocations
Learn how Rust iterators borrow, consume and short-circuit. Build clear pipelines, avoid needless collections, and keep malformed input visible.
· 11 min read
Crabrix blog
Learn how Rust iterators borrow, consume and short-circuit. Build clear pipelines, avoid needless collections, and keep malformed input visible.
· 11 min read
A small report rarely needs every intermediate version of its input. Suppose a weather station stores sensor readings and you want the sum of the squares of the even readings. You could copy the readings into a second vector, remove the odd values, build another vector of squares, then add everything together. That works, but the temporary collections are doing bookkeeping for the implementation rather than representing useful results in your program.
Rust iterators let you describe the transformations while leaving the decision about storage until the end. This is useful for ordinary application code: calculating a total, finding the first matching record, checking whether a configuration is valid, or producing a final list for a screen. The important question is not whether a chain looks sophisticated. Ask what the caller needs to receive. A number, a borrowed item and an owned collection imply different endings to the same pipeline.
We will use a tiny numeric example first, then examine the ownership and error handling decisions that become important with real text input. Every Rust program in this article uses the standard library. You can run these examples in Crabrix without installing a third-party dependency.

Our input is [2, 7, 4, 9]. Filtering for even values leaves 2 and 4. Squaring them gives 4 and 16, whose sum is 20. These deliberately small numbers make the whole calculation inspectable. Before changing a larger report to use iterator adapters, choose an equally small input and calculate the expected result by hand. You will notice a reversed filter or a missing conversion much faster than you would in a large output file.
The following program borrows its vector, copies each small integer into the pipeline, keeps the even ones, and sums their squares. The explicit i32 result type tells sum which numeric type to produce. The input is still available afterward because iter borrowed it. copied copies the integers; it does not clone the vector or allocate a new one. For these values, copying is a natural way to keep the closures easy to read.
Treat the assertion as part of the explanation. It states the result independently of how the adapters are arranged. If you later replace the chain with a loop, the same assertion should still describe the desired behavior.
fn main() {
let readings = vec![2_i32, 7, 4, 9];
let total: i32 = readings.iter().copied()
.filter(|value| value % 2 == 0)
.map(|value| value * value)
.sum();
assert_eq!(total, 20);
assert_eq!(readings.len(), 4);
println!("Total: {total}");
}Calling map or filter constructs an adapter. It does not, by itself, walk the entire input and manufacture an output list. A consumer such as sum, collect or find requests items. Each request passes through the adapters until a result can be returned or the input is exhausted. For our sum, the consumer eventually asks for everything, but each accepted value can be squared and accumulated without first storing a vector of accepted values.
That distinction explains a common debugging surprise: placing a print statement inside map and never consuming the iterator produces no prints from that closure. It also explains why find can stop early. Once it has found a matching value, it does not need to request the remaining items. Laziness describes when work is requested; it is not a promise that any particular chain is faster than a readable loop.
The infographic shows the logical stages, not separate buffers in memory. The crossed-out rejected values do not enter the square stage. In a real execution the consumer pulls values through the stages as needed. Keeping that direction in mind is more useful than imagining a sequence of fully populated conveyor trays.

Ownership becomes more visible when the items are strings instead of integers. A vector of String values owns their buffers. Calling iter yields shared references to those strings, so you can inspect them without transferring ownership. Calling iter_mut yields mutable references, allowing changes under Rust’s borrowing rules. Calling into_iter on the vector consumes the vector and yields its owned strings. The words look similar, but they answer different questions about who keeps the data.
Use borrowing when a later part of the program still needs the original collection. Consume it when the next stage should take responsibility for the owned items. A transformation from Vec<String> into another owned collection may naturally use into_iter, moving strings instead of cloning them. A read-only search normally does not need that transfer. Do not add clone automatically just because the compiler reports a moved value: first check whether the pipeline should have borrowed the collection.
There is no universal rule that consuming is better than borrowing. The correct choice follows the lifetime of the application’s data. A report can borrow a project’s records while rendering a summary; an import operation can consume a temporary batch after validating it. Make that choice explicit at the start of the chain.
A final collect is appropriate when a caller genuinely needs a collection. A screen might need a stable list that can be sorted, indexed or displayed repeatedly. A function might promise to return an owned Vec of validated rows. In those cases allocation serves the API. The questionable pattern is collecting only to immediately iterate over the temporary collection again, especially when the second pass merely computes a total or asks a yes-or-no question.
For a total, consider sum. For a condition across all items, consider all or any. For the first match, consider find. For an accumulated value with a custom rule, a loop or fold may be clearer. These consumers express an intention that a temporary vector can obscure. They also avoid requiring storage for results the caller never receives. However, a deliberate collection can simplify complicated code, and clarity is a legitimate reason to keep it.
When reviewing a chain, underline each place that creates owned storage: a collected vector, a new string, or a clone of a record. Then ask whether that storage escapes the current calculation. This is a much more precise review habit than treating every iterator as allocation-free or every collection as a mistake.
Searching a list of owned strings usually needs a reference to the matching string, not a copy of its contents. The example below finds the first project name with a particular prefix and prints it while leaving the original names in place. The returned reference cannot outlive the vector it points into. That restriction matches reality: a name borrowed from this list should not remain usable after the list has been destroyed.
Notice that find returns an Option. An empty list and a list with no matching prefix are ordinary possibilities, so the program handles the missing case explicitly. Replacing that handling with unwrap would silently change the function’s contract from “a match may exist” to “absence is a bug.” Sometimes that contract is justified, but a user’s search query rarely makes it safe to assume a result exists.
This pattern scales to larger records. Borrow the record during inspection and only build a separate owned representation when a later boundary needs it. That boundary might be persistent storage, a background task, or a response whose lifetime is independent of the source list.
fn main() {
let names = vec![String::from("notes"), String::from("weather-lab")];
match names.iter().find(|name| name.starts_with("weather")) {
Some(name) => println!("Found: {name}"),
None => println!("No matching project"),
}
assert_eq!(names.len(), 2);
}Iterator chains become dangerous when a convenient adapter quietly changes the meaning of the input. For example, parsing each line with filter_map(|line| line.parse::<i32>().ok()) discards every line that fails to parse. That may be intentional for an exploratory tool, but it is a poor default for a financial total, a configuration file or a sensor import where an invalid reading should stop the operation. A shorter chain is not clearer if it conceals missing data.
When every item must be valid, preserve the Result returned by parsing. Collecting an iterator of results into a Result<Vec<_>, _> stops at the first error instead of manufacturing a successful vector with omissions. That vector is useful if subsequent work needs all the parsed values. When you only need an accumulated total, a loop that parses, adds context and updates a checked accumulator can express the contract without building that collection.
Choose the policy before choosing the adapter: reject the batch, retain individual failures, or intentionally skip invalid rows while reporting how many were skipped. All three can be reasonable products. Accidentally losing errors because .ok() made a type mismatch disappear is not a policy.
A parse error such as “invalid digit found in string” may be technically correct and still leave the reader guessing which input needs repair. Attach the line number at the point where you still know it. enumerate provides a zero-based index; converting it to a one-based line number is a small presentation decision that makes diagnostics match what people see in an editor. Keep the original error available when your application’s error type supports it.
The program below intentionally rejects its second line. It does not claim to validate all possible inputs: the total itself could overflow with sufficiently large values, so a production importer also needs an explicit numeric range or checked addition. Separating these decisions makes the example honest. Parsing validates the spelling of one integer, while arithmetic validation protects the aggregate. Neither substitutes for the other.
In an application, a structured error with fields for the line and underlying cause is often better than a string. A compact string is sufficient here to show where context belongs. Keep diagnostics near the operation that understands the input rather than trying to reconstruct a line number after the chain has finished.
fn parse_lines(lines: &[&str]) -> Result<Vec<i32>, String> {
lines.iter().enumerate().map(|(index, line)| {
line.parse::<i32>()
.map_err(|error| format!("line {}: {error}", index + 1))
}).collect()
}
fn main() {
let error = parse_lines(&["12", "not a number", "7"]).unwrap_err();
assert!(error.starts_with("line 2:"));
println!("{error}");
}Not every sequence of operations wants to be an iterator chain. A loop is often easier to review when processing requires several related state changes, detailed diagnostics, a transaction boundary or an early return with a custom reason. Rust does not reserve efficiency for functional-looking code. A straightforward loop can borrow the same input and avoid the same unnecessary allocations as an adapter chain.
Consider an import that must count accepted rows, reject a duplicate identifier, and stop before an external write when the batch reaches a configured limit. Encoding every branch into nested closures may make the order of effects harder to understand. A loop can name each stage and keep the rollback or failure behavior visible. Conversely, a simple transformation from records to labels may become noisier if expanded into a loop with a mutable output vector.
Use the form that makes the invariant easiest to inspect. If there is a requirement such as “no writes occur after the first rejected row,” put it where a reviewer can follow the control flow. The goal is understandable behavior with deliberate ownership, not a maximum number of adapters per line.
Iterator syntax does not provide a portable speed ratio. Compiler optimization, data size, allocation patterns and the work inside each closure all matter. A microbenchmark over a handful of integers says little about a report dominated by string formatting or file I/O. Avoid performance claims based only on how a chain looks. First find a user-visible problem and identify which work accounts for it.
When measuring, compare implementations that produce the same answer and handle the same invalid inputs. Keep input preparation outside the timed operation if preparation is not what you intend to study. Use optimized builds for a desktop performance comparison, repeat measurements, and inspect variability rather than publishing a single attractive number. Memory behavior also deserves attention: removing a temporary collection may help peak storage without noticeably changing elapsed time.
The examples in this article are explanations, not benchmark results. Their concrete benefit is that ownership, validation and storage decisions are visible. If your own profiling later shows that one stage dominates execution, you will have a small, well-defined piece of code to investigate instead of a vague suspicion that iterators are either always fast or inherently expensive.
Read the pipeline from both ends. At the beginning, check whether it borrows, mutably borrows or consumes the source. At the end, check whether the consumer returns exactly what the caller needs. Between them, look for accidental cloning, unreported parsing failures and transformations whose assumptions are not stated. Then test a normal input, an empty input and a failure case. These checks expose more useful defects than rewriting code solely to reduce line count.
For our even-square total, empty input should produce zero. For a search, empty input should produce None. For a strict parser, one malformed line should return an error rather than a partial success. These are different contracts even when all three implementations begin with iter. Naming the contract gives you a stable basis for choosing adapters and for changing the implementation later.
A useful next exercise is to take a report you already understand and remove exactly one temporary collection. Keep its output and error behavior unchanged. If the revised code becomes harder to explain, keep the original or try a loop. Good iterator code is a clear agreement about data, work and results; brevity is a possible consequence, not the agreement itself.