How to Use zip and enumerate in Rust

Use zip to pair iterators and enumerate to add indices for processing items with their positions in Rust.

When you need indices or pairs

You are parsing a configuration file. Each line contains a key and a value. You split them into two separate vectors during preprocessing. Now you need to pair them back up to build a map. Or maybe you are rendering a list of items and need to know which row you are on to apply alternating background colors. In Python, you reach for zip() and enumerate(). In JavaScript, you fight with Array.prototype.entries() or manual index tracking. Rust gives you both, but they live inside the iterator system. They do not allocate new arrays. They do not copy your data. They hand you a stream of pairs, one at a time, exactly when you ask for them.

The conveyor belt model

Think of an iterator as a vending machine. You press a button, it drops one item. You press it again, it drops the next. The machine does not dump the entire inventory onto the floor at once. enumerate and zip are just attachments you bolt onto that machine. enumerate stamps a sequential ticket on every item as it comes out. zip takes two separate machines, runs them side by side, and hands you a pair of items each time you press the button. When either machine runs out of stock, the whole operation stops.

This lazy behavior is the core of Rust's iterator design. Chaining methods does not trigger work. The compiler builds a pipeline. Work only happens when you consume the pipeline, usually with a for loop, .collect(), or .for_each(). The pipeline stays in memory as a small state machine. It occupies a few bytes on the stack, not a new heap allocation.

Keep the pipeline tight. Let the for loop drive the iteration.

enumerate: stamping tickets

Start with a single collection. You want the index and the value.

/// Prints each word alongside its position in the list.
fn print_with_index() {
    let words = vec!["apple", "banana", "cherry"];

    // iter() yields &String references without copying.
    // enumerate() wraps each reference in (usize, &String).
    // The for loop pulls one pair at a time from the adapter.
    for (index, word) in words.iter().enumerate() {
        println!("{}: {}", index, word);
    }
}

Notice the destructuring pattern unpacks the tuple directly. iter() already yields references, so enumerate returns (usize, &String). If you want the actual string slice instead of a reference to the vector element, you can map it, but the loop above is the standard pattern. The compiler knows exactly how to unpack the tuple without allocating a new vector of tuples.

The adapter maintains a single usize counter. It starts at zero. Every time the loop requests the next item, the counter increments. The adapter never looks ahead. It never buffers. It simply pairs the current counter value with whatever the underlying iterator produces.

Trust the lazy pipeline. It does the heavy lifting without touching the heap.

zip: merging streams

Pairing two different collections requires zip. It is useful when you have parallel data sources that must stay synchronized.

/// Matches headers with their corresponding values for display.
fn display_config() {
    let headers = vec!["name", "age", "role"];
    let values = vec!["alice", "28", "admin"];

    // zip() pairs elements from both iterators.
    // It stops as soon as the shorter iterator is exhausted.
    // No intermediate vector is created.
    for (header, value) in headers.iter().zip(values.iter()) {
        println!("{}: {}", header, value);
    }
}

The zip adapter pulls one item from the left iterator and one from the right. It returns a tuple of both. If headers had five items and values had three, the loop runs exactly three times. The remaining two headers are silently ignored. This truncation behavior is intentional. It prevents index-out-of-bounds panics that plague manual indexing in other languages.

You can chain zip with other adapters. The pipeline grows, but the execution model stays the same.

/// Filters pairs where the value starts with 'a', then prints them.
fn filtered_pairs() {
    let keys = vec!["x", "y", "z"];
    let vals = vec!["alpha", "beta", "gamma"];

    // zip pairs them, filter keeps only matching ones,
    // for_each consumes the pipeline without a loop block.
    keys.iter()
        .zip(vals.iter())
        .filter(|(_, v)| v.starts_with('a'))
        .for_each(|(k, v)| println!("{} -> {}", k, v));
}

Let zip handle the synchronization. It will never panic over mismatched lengths.

The lazy pipeline and compiler optimization

When you write words.iter().enumerate().zip(other.iter()), you are not creating a new data structure. You are creating a chain of adapters. Each adapter implements the Iterator trait. The trait requires a single method: fn next(&mut self) -> Option<Self::Item>.

The for loop repeatedly calls .next() on the outermost adapter. That adapter calls .next() on its inner adapter. The chain unwinds until it hits the base iterator, which pulls the actual data from memory. The result bubbles back up, wrapped in tuples at each step.

The compiler optimizes this aggressively. Because the Iterator trait is a standard library trait with a single vtable method, and because the adapters are generic over the underlying iterator, the compiler monomorphizes the entire chain. It inlines every .next() call. The final machine code looks identical to a hand-written while loop with manual index tracking and bounds checking. This is the zero-cost abstraction principle in action. You write high-level declarative code. You get low-level imperative performance.

The memory layout reflects this efficiency. An Enumerate<Iter> struct contains exactly two fields: the inner iterator and a usize counter. A Zip<IterA, IterB> struct contains exactly two fields: the left iterator and the right iterator. No heap allocations. No hidden buffers. The stack footprint is predictable and minimal.

Do not pre-allocate vectors to hold paired data. Let the iterator chain stream the pairs directly into your processing logic.

Borrow checker traps

The borrow checker will stop you from zipping a collection with itself if you try to mutate one side while reading the other. This is a common trap for developers coming from languages with mutable arrays.

fn broken_zip() {
    let mut data = vec![1, 2, 3];

    // This fails to compile.
    // data.iter() borrows data immutably.
    // data.iter_mut() tries to borrow it mutably at the same time.
    for (old, new) in data.iter().zip(data.iter_mut()) {
        *new = *old * 2;
    }
}

The compiler rejects this with E0502 (cannot borrow as mutable because it is also borrowed as immutable). Rust cannot guarantee that the mutable write in the second half of the tuple won't overwrite the immutable read in the first half, especially since iterators are lazy and evaluation order is technically flexible. The borrow checker enforces strict separation.

If you need to pair elements with their indices for mutation, use iter_mut().enumerate() instead. It gives you (usize, &mut T), which is perfectly safe because the index is just a number, not a reference into the same collection.

fn safe_mutation() {
    let mut data = vec![1, 2, 3];

    // enumerate on iter_mut() yields (usize, &mut i32).
    // The index is a copy. The reference is exclusive.
    // No overlapping borrows occur.
    for (i, val) in data.iter_mut().enumerate() {
        *val *= i + 1;
    }
}

Another trap is expecting zip to pad the shorter iterator. It does not. If you need padding, you must implement it manually or use a crate like itertools which provides zip_longest. Stick to the standard library's truncation rule unless you have a specific reason to diverge.

When the borrow checker blocks a self-zip, it is protecting you from data races in your own loop. Change the strategy, do not fight the rule.

Chaining them together

You occasionally see enumerate().zip() in codebases. It is rare, but it has a specific use case. You need both the index and a paired value from a second collection.

/// Aligns timestamps with sensor readings and prints their sequence number.
fn log_readings() {
    let timestamps = vec!["10:00", "10:05", "10:10"];
    let readings = vec![42, 45, 41];

    // enumerate() adds the index.
    // zip() pairs the indexed timestamps with readings.
    // The loop destructures the nested tuple cleanly.
    for ((index, ts), val) in timestamps.iter().enumerate().zip(readings.iter()) {
        println!("Entry {}: {} -> {}", index, ts, val);
    }
}

The nested tuple ((usize, &str), &i32) looks verbose, but Rust's destructuring handles it without friction. You can flatten it with a single level of parentheses if you prefer. The pipeline remains lazy. The index counter and the two base iterators advance in lockstep.

The community convention here is to prefer zip over manual indexing whenever possible. If you find yourself writing for i in 0..len { let a = &arr1[i]; let b = &arr2[i]; }, you are doing the compiler's job manually. Replace it with .iter().zip(). The compiler will generate the same bounds checks, but the code reads like intent rather than mechanics.

Also, always prefer for (a, b) in iter1.zip(iter2) over .for_each(|(a, b)| ...). The for loop is idiomatic for side effects, while .for_each() is reserved for functional chains where you are returning a value or chaining further. The compiler treats them nearly identically, but the style guides draw a line here.

Write the pipeline. Let the destructuring unpack it.

Decision matrix

Use enumerate when you need a running counter for a single collection and want to avoid manual index tracking. Use zip when you have two parallel collections and need to process them side by side without worrying about length mismatches. Use manual indexing with for i in 0..len only when you need random access to multiple offsets or complex stride patterns that iterators cannot express cleanly. Use take and skip when you need to slice a stream by count rather than pairing it with another stream. Reach for itertools::zip_longest when you must process mismatched lengths and need a fallback value for the shorter side.

Where to go next