How to Use Vec<T> (Vectors) in Rust

The Complete Guide

Create a Rust vector with Vec::new() or vec![], add items with push(), and access them by index.

When a fixed array falls short

You are building a log parser. It reads a file line by line, extracts error messages, and stores them for later reporting. You do not know how many errors will appear. A fixed-size array forces you to guess upfront. If you guess too low, the program crashes. If you guess too high, you waste memory. You need a container that starts small, grows on demand, and keeps everything packed tightly in memory for fast iteration.

The container under the hood

Vec<T> is Rust's growable array. It lives on the heap and stores elements in contiguous memory. Contiguous means every item sits right next to the previous one, like seats in a movie theater. This layout gives you two massive advantages. First, the CPU can predict where the next item lives and prefetch it into cache. Second, you get constant-time indexing. Jump to the hundredth item and the computer calculates the exact memory address in a single arithmetic step.

Under the hood, a Vec tracks three numbers. The pointer tells the CPU where the heap data lives. The length tracks how many valid items are currently stored. The capacity tracks how much space the allocator actually reserved. Length never exceeds capacity. When you push an item and length equals capacity, the vector triggers a reallocation. It requests a larger chunk of memory, moves every existing element over, drops the old chunk, and updates the pointer. The growth strategy usually doubles the capacity, which keeps the average cost of pushing down to constant time.

Minimal example

fn main() {
    // vec![] allocates on the heap and inserts the initial values.
    // It infers the type from the literals, so no explicit Vec<i32> needed.
    let mut scores = vec![85, 92, 78];

    // push() appends to the end. It panics if the system allocator fails.
    scores.push(95);

    // Indexing uses square brackets. It checks bounds at runtime.
    // Out-of-bounds access triggers a panic, not silent memory corruption.
    let first = scores[0];
    println!("First score: {}", first);

    // len() returns the current number of valid elements.
    // capacity() reveals the reserved heap space, including unused slots.
    println!("Length: {}, Capacity: {}", scores.len(), scores.capacity());
}

What happens during a push

When the program runs, vec![85, 92, 78] asks the system allocator for space to hold three i32 values. The allocator hands back a pointer to a heap block. The vector records a length of three and a capacity of three. The push(95) call checks the capacity. Since length equals capacity, the vector requests a new block, typically double the size. It copies the four existing integers into the new block, frees the old memory, and updates its internal pointer. The length becomes four, and the capacity jumps to eight.

Indexing with scores[0] performs a bounds check. The compiler translates scores[0] into a call that verifies 0 < length. If the check passes, it computes pointer + 0 * size_of::<i32>() and reads the value. If you try scores[10], the check fails and the process panics. This panic is intentional. Rust refuses to return garbage data or wrap around to random memory.

Realistic usage patterns

Real code rarely pushes one item at a time in a tight loop. You usually transform data, filter it, and collect the results. The standard library provides iterator adapters that work seamlessly with vectors. You also want to avoid repeated reallocations when you know the approximate size upfront.

/// Filters a list of raw log lines and returns only the error messages.
/// Pre-allocates capacity to avoid mid-process reallocations.
fn extract_errors(raw_logs: &[&str]) -> Vec<String> {
    // with_capacity() reserves heap space immediately.
    // It prevents the vector from growing incrementally during iteration.
    let mut errors = Vec::with_capacity(raw_logs.len() / 4);

    for line in raw_logs {
        // starts_with() is a cheap prefix check.
        // to_string() allocates a new String on the heap.
        if line.starts_with("ERROR: ") {
            errors.push(line.to_string());
        }
    }

    errors
}

fn main() {
    let logs = ["INFO: started", "ERROR: disk full", "WARN: slow query", "ERROR: timeout"];
    let critical = extract_errors(&logs);
    println!("Found {} errors", critical.len());
}

Iteration patterns matter just as much as creation. v.iter() yields &T references. v.iter_mut() yields &mut T references. v.into_iter() consumes the vector and yields owned T values. The community convention is to prefer into_iter() when you no longer need the container, because it avoids unnecessary cloning and makes ownership transfer explicit. When you need to remove a range of elements and reuse the buffer, drain() returns an iterator that yields owned values while shrinking the length. The capacity stays intact for future pushes.

Pitfalls and compiler friction

The borrow checker and Vec interact in ways that trip up newcomers. You cannot hold a reference to an element while pushing new items. Pushing might trigger a reallocation, which invalidates every existing pointer. If the compiler allowed it, your reference would point to freed memory.

fn main() {
    let mut data = vec![10, 20, 30];

    // This compiles. We borrow the first element immutably.
    let first = &data[0];

    // This fails. push() requires a mutable borrow of the entire Vec.
    // The compiler rejects it with E0502 (cannot borrow as mutable because it is also borrowed as immutable).
    // data.push(40);

    // The fix: drop the reference before mutating, or restructure the logic.
    println!("First before push: {}", first);
    drop(first);
    data.push(40);
}

Another common trap is confusing Vec<T> with slices &[T]. A Vec owns its data and can grow. A slice is a view into existing data. It carries a pointer and a length, but no capacity. Functions should accept slices whenever they only need to read or iterate. This lets callers pass Vec, arrays, or string literals without forcing unnecessary allocations.

Indexing with [] panics on out-of-bounds access. If you need to handle missing indices gracefully, use get(). It returns Option<&T> and never panics. The same rule applies to mutable access: get_mut() returns Option<&mut T>. Panics are for programming errors. Options are for expected absence.

Convention aside: the community prefers vec![] over Vec::new() for initialization. Both produce an empty vector, but vec![] signals intent clearly and works with type inference. When you need to discard a vector's contents without deallocating the buffer, use clear(). It drops every element and resets the length to zero, but keeps the capacity intact. Reuse the buffer instead of paying for a fresh allocation.

Removing elements from the middle of a Vec is expensive. remove(index) shifts every subsequent element down to fill the gap. That is an O(n) operation. If order does not matter, use swap_remove(index). It copies the last element into the gap and pops the end. That is O(1). Pick the right tool for the job.

When to reach for what

Use Vec<T> when you need a growable, owned collection with fast indexing and iteration. Use &[T] when a function only reads or iterates over data and should accept any contiguous sequence. Use VecDeque when you frequently push and pop from both the front and the back. Use LinkedList when you need constant-time splicing of large chunks and do not care about cache locality or indexing speed.

Where to go next