Vec<T> In-Depth

A thorough guide to Rusts Vec collection covering internal layout element access capacity management transformations sorting and how ownership prevents common pitfalls

A Vec<T> is a heap-allocated, growable array. If you have used lists in Python or std::vector in C++, the concept is familiar: you can add or remove elements, the collection resizes automatically, and every element lives at a predictable index. Rust’s Vec does all of this while keeping the ownership and borrowing rules that protect you from dangling pointers and data races.

The data lives in a single contiguous block of memory. That matters because modern CPUs love predictable, linear memory access. Iterating over a Vec is fast for the same reason looping over a plain array is fast. When you push an element past the current capacity, the whole block may get moved to a larger allocation, but the amortized cost of appending stays constant.

Stack of boxes:

Think of a Vec as a shelf with numbered slots. You can jump straight to slot 3, add a new box at the end, or remove the last box instantly. If the shelf overflows, you move everything to a bigger shelf — which takes time but happens rarely.


Memory layout and capacity

Under the hood a Vec<T> is three words: a pointer to the heap allocation, the number of elements currently stored (len), and the total number of elements the allocation can hold before a reallocation is needed (capacity).

  ptr      len   capacity
+--------+-----+----------+
| 0x0123 |  2  |    4     |
+--------+-----+----------+
     |
     v
  +------+------+----------+----------+
  | 'a'  | 'b'  | uninit   | uninit   |
  +------+------+----------+----------+

The memory after the initialized elements is logically uninitialized — space the Vec already owns but is not yet using. When len == capacity, the next push triggers a reallocation to a larger block. The growth factor is not specified, but it guarantees amortised O(1) pushes.

Capacity is not length:

Beginners often assume capacity tells how many elements are present. It doesn’t. Call len() for the count of live elements; capacity() only tells you how much room is available before the next costly resize.

Capacity is something you can control. If you know roughly how many elements you will insert, create the vector with Vec::with_capacity so it allocates once. Otherwise the Vec will repeatedly grow, copy elements, and deallocate — visible as latency spikes in tight loops.


Accessing elements

Indexing and the Index trait

Square brackets give direct access by position. The operation performs a bounds check at runtime. If the index is out of range, the thread panics — no garbage data, no undefined behaviour, but a crash.

let numbers = vec![10, 20, 30];
let second = numbers[1]; // 20
// let bad = numbers[5]; // panic at runtime

Safe access with get

get returns Option<&T>. You decide what to do when the index is absent. This is the right choice for any index that might come from user input or an untrusted source.

if let Some(&value) = numbers.get(2) {
    println!("Third element is {}", value);
}

Mutable access, first, and last

get_mut hands out a mutable reference if the index exists. first and last return references to the ends — useful for stacks and queues.

if let Some(last) = numbers.last_mut() {
    *last += 5;
}

Indexing panics on out-of-bounds:

Calling v[n] on an index that does not exist crashes the program. In production code, use get or get_mut unless you have already verified the length. Crashing on an index that came from outside your control is a denial-of-service vector.


Growing and shrinking vectors

Push, pop, and the stack pattern

push appends a value to the end. pop removes and returns the last value as Option<T>. Together they turn a Vec into an efficient stack — all operations happen at the tail, O(1) amortised for push and O(1) for pop.

let mut stack = Vec::new();
stack.push(1);
stack.push(2);
while let Some(top) = stack.pop() {
    println!("{}", top); // prints 2, then 1
}

Insert and remove (expensive)

insert shifts every element after the insertion point one position to the right — O(n). remove shifts everything left. Use them sparingly on large vectors.

let mut v = vec!['a', 'b', 'd'];
v.insert(2, 'c'); // ['a', 'b', 'c', 'd']
let removed = v.remove(1); // 'b', shifts 'c' and 'd' left

Swap-remove for order-agnostic removal

If you do not care about preserving the original order, swap_remove replaces the target element with the last element and pops. This is O(1).

let mut v = vec![10, 20, 30, 40];
v.swap_remove(1); // takes 20, moves 40 to index 1 → [10, 40, 30]

Draining and truncation

drain gives you an iterator that removes a range and yields the extracted elements. It is useful for splitting a vector without extra allocation. truncate shortens the vector to a given length, dropping the excess elements. clear removes everything but keeps the backing allocation intact.

Remove shifts elements:

Calling remove(0) on a vector of 100,000 items moves 99,999 elements one position to the left. Use VecDeque if you need frequent front removal, or store elements in reverse and pop from the end.

Building with preallocated capacity

When the final size is known ahead of time, preallocating avoids repeated reallocations. The steps are sequential, and each step builds on the previous one.

1

Step 1: Determine the upper-bound size

Estimate how many elements the vector will hold. Even an approximate upper bound helps. If you are collecting results from a loop, you might know the iteration count.

2

Step 2: Create the vector with `with_capacity`

let mut buffer = Vec::with_capacity(10_000);

This allocates space for 10,000 elements in one heap allocation.

3

Step 3: Push elements without reallocation

for i in 0..10_000 {
    buffer.push(i * 2);
}

No reallocation occurs because the capacity was large enough from the start.

The same idea applies to reserve if you already have a vector and you know a large batch is coming. shrink_to_fit (or the newer shrink_to) reclaims unused capacity after the final size is settled, but it may cause a reallocation and copy, so only call it when memory pressure is real.


Joining, splitting, and swapping

Extending and appending

extend_from_slice copies elements from a slice onto the end. append moves every element from another Vec into the caller, leaving the source empty. It is a cheap operation — only the pointer, length, and capacity of the source are updated, not the elements themselves.

let mut a = vec![1, 2, 3];
let mut b = vec![4, 5];
a.append(&mut b);
// a is [1, 2, 3, 4, 5], b is []

Split at an index

split_off splits the vector in two at a given index. The original keeps elements 0..at, and the returned Vec owns at..len. Elements are moved, not copied — an O(n) operation because the tail must be physically relocated into a new allocation.

let mut original = vec![1, 2, 3, 4, 5];
let second_half = original.split_off(2);
// original = [1, 2], second_half = [3, 4, 5]

split_at and split_at_mut produce two borrows of the same slice, separated at an index. The borrow checker guarantees no overlapping mutable references.

Swapping elements

swap exchanges two elements in place. No allocation, O(1). swap_remove was already discussed; it is effectively a swap followed by a pop.


Sorting and searching

Sorting methods

sort performs a stable, adaptive merge sort. Equal elements keep their relative order. sort_unstable uses a pattern-defeating quicksort variant that is generally faster but does not preserve order for equal items.

let mut values = vec![9, 5, 2, 8, 5];
values.sort();          // [2, 5, 5, 8, 9]
values.sort_unstable(); // [2, 5, 5, 8, 9] (order of 5s may differ)

sort_by accepts a comparator closure. sort_by_key derives a key for each element, which is ideal for sorting structs by a field. sort_by_cached_key computes each key only once, useful when key extraction is expensive.

binary_search returns Ok(index) if the value is present and Err(insertion_index) if it is not. The slice must be sorted in ascending order; otherwise the result is arbitrary (but still safe).

let data = vec![2, 5, 5, 8, 9];
match data.binary_search(&5) {
    Ok(index) => println!("Found at {}", index), // some index, e.g., 1
    Err(pos) => println!("Can be inserted at {}", pos),
}

binary_search_by and binary_search_by_key let you work with custom comparators or key functions, mirroring the sorting variants.

Unsorted input to binary_search:

Calling binary_search on an unsorted vector will not crash, but it returns a meaningless result. There is no runtime error. Always ensure the vector is sorted before calling any binary search method.

Sort is destructive:

Methods like sort mutate the vector in place. If you need to preserve the original order, clone first. The ownership system makes it obvious: you pass &mut self, not &self.


Comparing slices

Vec<T> compares with Vec<T> and with &[T] element-by-element, as long as T implements PartialEq (for ==, !=) and Eq (where total equality is required). For ordering comparisons, T must implement PartialOrd / Ord.

let a = vec![1, 2, 3];
let b = vec![1, 2, 3];
let c = vec![1, 2, 4];
assert_eq!(a, b);
assert!(a < c);   // lexicographic: 3 < 4

Lexicographic order means the first differing element decides the comparison. If one vector is a prefix of the other, the shorter vector is considered “less.”


Random element access

The standard library does not include random number generation, so Vec has no dedicated method for random access. The common pattern is to generate a random index with an external crate like rand and then use get.

use rand::Rng;
let items = vec!["apple", "banana", "cherry"];
let mut rng = rand::thread_rng();
if let Some(random_item) = items.get(rng.gen_range(0..items.len())) {
    println!("Random pick: {}", random_item);
}

Using get instead of square brackets prevents a panic when the random range logic is wrong or the vector is empty.


How Rust rules out invalidation errors

In languages without a borrow checker, modifying a dynamic array while holding a pointer into it can cause use-after-free bugs. Rust prevents this at compile time.

The problem Rust blocks

If Rust allowed simultaneous mutable and immutable borrows, you could push to a vector while iterating over it. A reallocation during push could move the entire backing store, invalidating the iterator’s internal references. In C++ that leads to silent memory corruption. In Rust the compiler refuses to compile.

let mut v = vec![1, 2, 3];
for item in &v {
    v.push(*item * 10); // ❌ cannot borrow `v` as mutable because it is also borrowed as immutable
}

Working patterns

Collect into a new vector. This avoids shared and exclusive borrows coexisting.

let v = vec![1, 2, 3];
let doubled: Vec<_> = v.iter().map(|x| x * 2).collect();

Iterate with an index. You can safely modify a vector while looping with an integer counter because each access is a fresh bounds-checked lookup, not a long-lived reference.

let mut v = vec![1, 2, 3];
for i in 0..v.len() {
    v[i] *= 2;
}

Use split_at_mut for disjoint mutable access. This method lets you take two non-overlapping mutable slices from the same vector.

let mut v = vec![1, 2, 3, 4, 5];
let (left, right) = v.split_at_mut(2);
left[0] += 10;
right[1] += 20;

Each mutable reference is to a distinct region, so the borrow checker accepts it.

Compile-time protection:

The borrow checker guarantees you never hold a dangling reference into a Vec. The error messages, while sometimes intimidating for newcomers, describe the exact reason the operation is unsafe. Over time they become a guide rather than a wall.

Reallocation and references

Even without iteration, holding a reference to an element while pushing is forbidden. If the push causes a reallocation, the reference would point into freed memory.

let mut v = vec![10, 20];
let first = &v[0];
v.push(30); // ❌ cannot borrow `v` as mutable because it is also borrowed as immutable
println!("{}", first);

The fix is to release the reference before the push. In practice this means structuring code so mutable and immutable borrows never overlap.


Summary

A Vec is simple on the surface — a growable array — but its power comes from three design choices that work together. The contiguous memory layout makes iteration cache-friendly and predictable. The capacity management strategy gives you constant-amortised appends and a way to avoid reallocations when you know the target size. And Rust’s ownership model eliminates the entire category of invalidation bugs that plague array-based collections in other systems languages.

The single most important habit to form early is using with_capacity whenever the final size is known or has a reasonable bound. It costs little and removes the hidden performance cost of repeated reallocations. Pair that with get for safe index access and you avoid the two most common sources of pain.