When position matters
You're parsing a log file and the error reporter needs to show line numbers. You're building a diff tool and need to know exactly which characters changed. You're processing a stream of events and need sequence numbers for replay. In Python, you'd reach for enumerate(). In JavaScript, you'd use entries() or pass an index to forEach. Rust gives you .enumerate() too, but it fits into the iterator chain in a way that changes how you think about indexing.
Rust's iterator system treats data as a stream. .enumerate() is a combinator that attaches a counter to that stream. It doesn't allocate a new collection. It doesn't copy data. It pairs each item with its position as the item flows through the pipeline. This makes it the standard, zero-cost way to access indices in Rust.
How enumerate works
.enumerate() takes an iterator and returns a new iterator that yields tuples of (index, item). The index starts at zero and increments by one for each element. The type of the index is usize, which matches Rust's array indexing.
Think of a factory assembly line. Raw parts come down the belt. The .enumerate() station is a robot arm that grabs each part, stamps it with a serial number, and puts it back on the belt. The parts haven't changed. You just have more information attached to them now. The serial numbers are sequential and start at zero.
The method works on any iterator. You can call it on a vector, a slice, a string, or a custom iterator. It borrows the underlying iterator and wraps it. The result is lazy. Nothing happens until you consume the iterator.
Minimal example
Here is the basic pattern. You call .iter() to get a borrowing iterator, then chain .enumerate(). The loop destructures the tuple into an index and the item.
fn main() {
let items = vec!["alpha", "beta", "gamma"];
// .iter() borrows the vector.
// .enumerate() pairs each borrowed item with a usize index.
for (index, item) in items.iter().enumerate() {
println!("Index {}: {}", index, item);
}
}
The output shows indices starting at zero. The item variable holds a reference to the string inside the vector. The index is a copy of the counter. You can use both values freely inside the loop.
Don't reach for a counter variable. The iterator tracks position for you.
Lazy evaluation and zero cost
.enumerate() returns an iterator, not a collection. It doesn't create a vector of tuples. It doesn't allocate memory. It just holds a reference to the original iterator and a counter.
When you call .next() on the enumerated iterator, it asks the underlying iterator for the next item. It pairs that item with the current counter value, increments the counter, and returns the pair. This lazy behavior means you can enumerate infinite streams without blowing up memory. The cost is essentially zero beyond the counter increment.
You can chain .enumerate() with other iterator methods like .filter(), .map(), or .take(). The enumeration happens on demand. If you .take(5), the counter only increments five times. The rest of the data is never touched.
Trust the iterator. It won't let you index past the end.
Finding indices with filter_map
A common pattern is finding the positions of specific values. You want a list of indices where an element matches a condition. .enumerate() combined with .filter_map() handles this cleanly.
/// Returns indices of all elements matching the target.
fn find_indices(data: &[&str], target: &str) -> Vec<usize> {
// .enumerate() provides the index.
// .filter_map() keeps only matches and extracts the index.
data.iter()
.enumerate()
.filter_map(|(idx, &val)| {
if val == target {
Some(idx)
} else {
None
}
})
.collect()
}
fn main() {
let words = vec!["rust", "is", "fast", "rust", "is", "safe"];
let indices = find_indices(&words, "rust");
println!("{:?}", indices); // [0, 3]
}
The closure receives (usize, &&str). The pattern &val dereferences the reference to compare the string slice. If the value matches, the closure returns Some(idx). Otherwise, it returns None. .filter_map() discards the None values and unwraps the Some values, leaving a stream of indices. .collect() gathers them into a vector.
This pattern avoids manual index tracking and keeps the logic declarative. The compiler guarantees the indices are valid because they come from the iterator.
Custom start indices with zip
.enumerate() always starts at zero. If you need a custom start index, like line numbers starting at 1, use .zip() with a range iterator.
fn display_lines(lines: &[&str]) {
// .zip() pairs items with a range starting at 1.
// The range is infinite, so it won't stop the iteration.
for (line_num, line) in lines.iter().zip(1..) {
println!("{:3}: {}", line_num, line);
}
}
fn main() {
let code = vec!["fn main() {", " println!(\"hi\");", "}"];
display_lines(&code);
}
The range 1.. produces 1, 2, 3, ... indefinitely. .zip() pairs each line with the next number from the range. The loop yields (1, "fn main() {"), (2, " println!(\"hi\");"), and so on.
Use zip for offsets. enumerate is locked to zero.
There's a convention here. When you use .zip() for indexing, the range should be infinite or longer than the data. If the range is shorter, .zip() stops when the range ends, dropping items from the data. This is a safety feature. It prevents you from accidentally processing more items than you have indices for. Just make sure the range doesn't end prematurely.
Enumerate versus manual indexing
In C or Java, you might write a loop with a manual counter. In Rust, you can do for i in 0..arr.len(). But .enumerate() is safer and more idiomatic.
Manual indexing requires you to access the array by index inside the loop. If you make a mistake, you can panic with an index-out-of-bounds error. .enumerate() ties the index to the item. The index comes from the iterator, so it's guaranteed to match the item. You can't accidentally access arr[i+1] and panic if i is the last element, because the iterator stops exactly when the data stops.
The index is derived from the data, not external state. This eliminates a whole class of bugs.
Parallel enumeration with Rayon
If you're using the rayon crate for parallel iteration, .enumerate() works there too. It assigns indices deterministically based on position, even when the work is split across threads.
use rayon::prelude::*;
/// Sums the product of index and value in parallel.
fn sum_squared_indices(data: &[i32]) -> i64 {
// .par_iter() creates a parallel iterator.
// .enumerate() assigns indices based on original position.
data.par_iter()
.enumerate()
.map(|(i, &val)| (i as i64 * val as i64))
.sum()
}
fn main() {
let values = vec![10, 20, 30, 40];
let result = sum_squared_indices(&values);
// 0*10 + 1*20 + 2*30 + 3*40 = 200
println!("{}", result);
}
The indices are assigned before the parallel split. Thread 0 might process items 0 and 1, while Thread 1 processes items 2 and 3. The indices remain correct. You don't need to worry about race conditions on the counter. Rayon handles the coordination.
Parallel enumeration is useful when you need position-dependent calculations on large datasets. The performance gain comes from the parallel map, not the enumeration itself.
Pitfalls and compiler errors
The most common pitfall is calling .enumerate() on a value that implements IntoIterator directly. This moves the value into the iterator. You lose access to the original variable.
fn main() {
let v = vec![1, 2, 3];
// .into_iter() moves v into the iterator.
let iter = v.into_iter().enumerate();
// v is gone. The compiler rejects this.
// println!("{:?}", v); // Error E0382: use of moved value
}
The compiler rejects this with E0382 (use of moved value). If you need to keep the vector, call .iter() first to borrow it.
Another issue is expecting .enumerate() to return owned values. If you call .iter().enumerate(), the items are references. If you need owned values, call .into_iter().enumerate(), but remember this consumes the collection.
Keep the unsafe surface zero. enumerate is safe.
Decision matrix
Use .enumerate() when you need the zero-based index of elements in a single sequence. It's the standard tool for tracking position without manual state.
Use .zip(start..) when you need a custom starting index, such as 1-based line numbers or offset addresses. The range iterator handles the offset cleanly.
Use .zip(other_iter) when you need to process two sequences in parallel. enumerate only handles one sequence and a counter.
Use a manual loop with a counter when you need to skip indices, reset the count conditionally, or perform side effects that depend on non-linear indexing.