The warehouse keycard problem
You write a function that reads a thousand log lines from a file and stores them in a Vec. You pass that vector to a parser function. Immediately after the call, you try to print the original vector to verify the load. The compiler refuses to compile. It tells you the value was moved. You did not copy the data. You handed over the controls.
This is the exact moment Rust ownership stops being abstract and starts dictating how you structure your code. Collections like Vec, HashMap, and String follow the same rules as every other type in the language. They just feel heavier because they manage heap memory. Understanding how they move, borrow, and slice is the difference between fighting the borrow checker and using it as a design tool.
What a Vec actually controls
A Vec<T> does not contain your data. It contains a three-word control panel. On a 64-bit system, that panel is exactly twenty-four bytes: a pointer to the heap, a length counter, and a capacity counter. The actual elements live somewhere else in memory.
Think of the Vec as a warehouse keycard. The keycard tells the security system where the goods are, how many boxes are currently stored, and how many more boxes fit on the shelves. When you assign let v2 = v1, you are not teleporting the boxes. You are photocopying the keycard and handing it to someone else. The security system immediately deactivates the original card. v1 becomes useless. v2 now controls the warehouse.
This design is deliberate. Copying millions of bytes every time you pass a collection around would destroy performance. Rust moves the twenty-four-byte control panel instead. The heap allocation stays put. The cost is a single pointer copy.
The minimal move
Watch how the compiler tracks the control panel.
fn main() {
// Creates a control panel on the stack.
// Allocates heap memory for three integers.
let v1 = vec![10, 20, 30];
// Copies the control panel to v2.
// v1 is immediately invalidated by the compiler.
let v2 = v1;
// v2 now owns the heap allocation.
println!("v2 has {} items", v2.len());
// This line would trigger E0382: use of moved value.
// println!("{:?}", v1);
}
The compiler rejects any attempt to use v1 after the assignment. It knows the control panel moved. It does not care that v2 might go out of scope later. Ownership is linear. One owner at a time.
Walking through the stack and heap
When the compiler sees let v2 = v1, it generates code that copies twenty-four bytes from v1's stack slot to v2's stack slot. The heap pointer, length, and capacity are identical in both slots for a single cycle. Then the compiler marks v1 as uninitialized. Any subsequent read of v1 fails at compile time.
The heap memory never moves. The allocator does not copy the integers. It does not zero out the old stack slot. It simply stops tracking v1 as a valid owner. When v2 eventually goes out of scope, the destructor runs once. It reads the capacity, tells the allocator to free that exact block, and the memory is reclaimed.
If you need to keep the original collection alive, you must borrow it. Borrowing creates a temporary handle that points to the control panel. The compiler tracks how many handles exist. When the last handle goes out of scope, the original owner regains full control. No heap allocation moves. No data copies. Just pointer arithmetic and lifetime tracking.
A realistic data pipeline
Production code rarely moves collections back and forth. It borrows them, processes them, and returns results. Look at how a typical data transformation pipeline handles ownership.
/// Filters out negative numbers and returns a new owned Vec.
/// Takes a slice to avoid taking ownership of the input.
fn filter_positives(input: &[i32]) -> Vec<i32> {
// Creates a new Vec on the heap.
// Pushes only values that satisfy the predicate.
input.iter().copied().filter(|&x| x > 0).collect()
}
fn main() {
// Simulates reading raw data from a file or network.
let raw_data = vec![-5, 10, -2, 30, 0, 45];
// Borrows the data. filter_positives cannot drop it.
let cleaned = filter_positives(&raw_data);
// raw_data remains fully usable for logging or backup.
println!("Raw: {:?}", raw_data);
println!("Cleaned: {:?}", cleaned);
}
The function signature input: &[i32] is the boundary. It says this function only needs to read elements. It will not resize the collection. It will not take ownership. The caller keeps the warehouse keycard. The function gets a temporary visitor pass. When the function returns, the pass expires. The caller still owns everything.
Common pitfalls and compiler signals
The borrow checker will stop you before you cause a use-after-free or a double-free. The errors feel aggressive until you learn what they are protecting you from.
The most frequent error is E0382: use of moved value. It appears when you assign a Vec to a new variable, pass it to a function that takes ownership, or iterate with into_iter(), then try to use the original variable. The fix is usually borrowing with &vec or cloning with .clone().
You will also hit E0502: cannot borrow as mutable because it is also borrowed as immutable. This happens when you hold a reference to an element while trying to modify the Vec itself. Rust prevents iterator invalidation at compile time. If you need to filter or remove items, use methods like retain() or drain() that handle the bookkeeping safely.
Another trap is assuming Vec implements Copy. It does not. The control panel contains a heap pointer. Copying it blindly would create two owners for the same memory, guaranteeing a double-free when both scopes end. Rust forces you to choose: move, borrow, or clone. There is no silent middle ground.
Choosing the right boundary
Collection ownership is a design decision. Pick the boundary that matches your intent.
Use Vec<T> when you are the sole owner and need to push, pop, or resize the data. Use &[T] when a function only needs to read a sequence of elements and should accept arrays, vectors, or slices. Use &mut Vec<T> when a function needs to modify the collection in place but does not take ownership. Use .clone() when you genuinely need an independent copy and have measured that the allocation cost fits your performance budget. Use .into_iter() when you want to consume the collection and extract owned values, typically to move them into another container or process them one final time.
Reach for slices over &Vec<T> in function signatures. It is a small change that pays off in flexibility and clearer intent. Trust the borrow checker when it blocks a move. It is usually pointing out a design flaw where ownership should have been shared or borrowed from the start.