When the pipeline needs outside help
You have a list of raw sensor readings. Some are noise. You need to filter the bad ones, scale the good ones, and pack them into a new buffer. In Python, you write a list comprehension. In JavaScript, you chain .filter().map(). In Rust, you reach for iterators and closures. The combination feels magical until you hit a borrow checker error that stops you cold. Understanding how they actually talk to each other turns that friction into a reliable workflow.
How closures and iterators actually talk
Closures are anonymous functions that capture variables from their surrounding scope. Iterators are lazy pipelines that yield items one at a time. When you pass a closure to an iterator method, you are handing the pipeline a set of instructions plus a mini toolkit. The toolkit contains whatever variables the closure needs to do its job. The pipeline runs the instructions on each item, passes the result down the line, and stops when it runs out of data.
Think of a factory conveyor belt. The belt is the iterator. The workers standing at each station are the closures. Each worker grabs whatever tools they need from the nearby shelf before the shift starts. The belt never stops moving until the final station packs the product. Rust's compiler handles the tool grabbing automatically. It looks at your closure body, sees which variables you reference, and decides whether to borrow them or move them into the closure. That automatic capture is what makes the syntax clean. It is also what trips up beginners when the compiler disagrees with their assumptions.
The minimal pattern
Start with a collection and a value that lives outside it. Pass a closure to map that uses both. Collect the result.
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
let multiplier = 2;
// Create an iterator over references, transform each item, and collect back into a Vec
let doubled: Vec<i32> = numbers
.iter()
.map(|&n| n * multiplier)
.collect();
println!("{:?}", doubled);
}
The closure |&n| n * multiplier does two things at once. The &n pattern destructures the reference that iter() yields, giving you the raw i32 value. The multiplier variable is captured by reference automatically because the closure only reads it. The compiler inserts the borrow behind the scenes. You get clean syntax without manual & or * clutter.
Convention aside: write |&n| when iter() yields &T and you need T. Write |n| when into_iter() yields owned T. The explicit pattern match makes your intent readable to other Rust developers.
What happens under the hood
Iterators in Rust are lazy. Calling .iter() on a Vec does not copy the data. It returns a lightweight struct that holds a pointer to the first element and a length counter. Calling .map() does not run the closure. It returns a new iterator struct that wraps the previous one and stores your closure inside. Nothing executes until you call a consuming method like .collect(), .for_each(), or .count().
When .collect() runs, the pipeline activates. The Map iterator pulls one item from the Slice iterator, passes it to your closure, takes the result, and pushes it into the output collection. This repeats until the source iterator returns None. The closure runs exactly once per item. No intermediate vectors are created. No hidden allocations occur.
The compiler also infers the closure's trait bound. Iterator methods like map are generic over a type F that implements FnMut. That means the closure is allowed to mutate captured state. If your closure only reads, the compiler still satisfies FnMut because Fn is a subtype of FnMut. If you try to mutate a captured variable inside a map, the compiler checks whether the variable is declared mut. If it is not, you get E0596 (cannot borrow as mutable, as it is not declared mutable). If you try to move a captured value out of a closure that runs multiple times, you get E0382 (use of moved value). The trait system enforces the rules at compile time.
Trust the lazy pipeline. It does exactly what you tell it to do, once, with zero overhead.
A realistic data pipeline
Real code rarely doubles numbers. It parses logs, filters configuration lines, or transforms database rows. Here is a pattern that appears in production systems.
fn process_config_lines(raw_lines: Vec<String>) -> Vec<(String, String)> {
// Filter out comments and empty lines, then split each line into key-value pairs
raw_lines
.into_iter()
.filter(|line| !line.starts_with('#') && !line.trim().is_empty())
.filter_map(|line| {
// Split on the first '=' and return None if the format is invalid
let parts: Vec<&str> = line.splitn(2, '=').collect();
if parts.len() == 2 {
Some((parts[0].trim().to_string(), parts[1].trim().to_string()))
} else {
None
}
})
.collect()
}
The first filter keeps only valid configuration lines. It borrows each String because filter expects a closure that takes &T. The second stage uses filter_map to combine filtering and transformation. It splits the line, checks the result length, and returns Some for valid pairs or None for malformed ones. The collect() at the end builds a Vec<(String, String)>. The type annotation on the function signature guides the compiler's inference. You do not need to annotate every intermediate step.
Convention aside: prefer filter_map over chaining .filter().map() when the transformation can fail. It reads cleaner and avoids creating intermediate iterator adapters. The compiler inlines both versions identically, but filter_map signals intent to human readers.
Where the borrow checker bites back
Closures capture variables by reference by default. That default saves allocations but creates friction when you need ownership or mutation. The compiler errors are specific. Learn to read them.
If you try to move a Vec into a closure that runs multiple times, the compiler rejects you with E0382 (use of moved value). The iterator will call your closure once per item. Moving the Vec on the first call leaves nothing for the second call. Fix it by borrowing the Vec inside the closure, or by using move only when the closure runs exactly once (like in find or any).
If you declare a variable as immutable but try to mutate it inside a map or for_each, you get E0596. Iterator methods expect FnMut closures. The captured variable must be declared mut in the outer scope. Add let mut counter = 0; before the chain. The compiler will then allow the mutation.
If you pass a closure to a method that expects Fn but your closure captures a RefCell or tries to mutate state, you get E0277 (trait bound not satisfied). The method requires a closure that can be called multiple times without mutating captured data. Switch to a method that accepts FnMut, or isolate the mutation in a separate step.
If you forget the & in your closure parameter when using iter(), you get E0308 (mismatched types). iter() yields &T. Your closure expects T. The compiler cannot automatically dereference for you in that position. Add the & pattern or switch to into_iter() if you actually want owned values.
Treat the error code as a map. It points directly to the mismatch between your closure's capture mode and the iterator's execution contract.
Picking the right iterator method
Choose your tools based on what the pipeline needs to do. Use iter() when you only need to read items and want to avoid moving data out of the collection. Use into_iter() when you want to consume the collection and take ownership of each item. Use iter_mut() when you need to modify items in place without allocating a new collection. Use filter when you want to drop items based on a condition without changing their type. Use map when you want to transform every item into a new type or value. Use filter_map when the transformation can fail and you want to drop the failures automatically. Use for_each when you want to perform side effects like printing or logging and do not need a return value. Use collect when you need to materialize the pipeline into a concrete collection like a Vec, HashMap, or String.
Match the method to the data flow. The compiler will enforce the rest.