The allocation spike you didn't write
You're parsing a log file. You read lines, split them, and push tokens into a vector. Your profiler screams. The CPU is stuck in malloc and memcpy. You didn't write that code. The allocator did. You're fighting the growth pattern of the vector.
Rust's Vec grows by doubling. Capacity starts at zero. You push one item, it allocates space for one. You push a second, it allocates space for two, copies the first item, and drops the old block. You push a third, it allocates four, copies two, and drops. The capacity sequence goes 0, 1, 2, 4, 8, 16. Every doubling triggers a new allocation and a copy of all existing elements.
This geometric growth keeps the average cost of a push low. The total number of copies is proportional to the final size. For small integers, the cost is invisible. For large structs, or when you care about latency spikes, the doubling strategy hurts. The copy operation takes time. The allocation takes time. The OS has to find a free block. If you know the size, you can skip all the moves.
Pre-allocation in plain words
Think of a Vec like a moving box that magically expands. When you add an item and the box is full, Rust doesn't just stretch the cardboard. It rents a bigger box, moves everything over, throws away the old box, and puts your new item in. That move costs time.
Vec::with_capacity(n) tells Rust to rent the big box from the start. You still put items in one by one, but the box never moves. You pay for the space upfront. You save the cost of every move.
The name with_capacity is precise. It sets the capacity, not the length. The vector is still empty. You can't access elements by index yet. You must push items to fill the slots. Capacity is the promise of space. Length is the count of data.
/// Pre-allocates a vector to avoid reallocation overhead.
fn main() {
// We know we need exactly 1000 items.
// Reserve space upfront so push() never triggers a reallocation.
let mut numbers = Vec::with_capacity(1000);
// Capacity is 1000. Length is 0.
// Accessing numbers[0] would panic here.
assert_eq!(numbers.capacity(), 1000);
assert_eq!(numbers.len(), 0);
for i in 0..1000 {
// This push is O(1).
// No allocation. No copy. Just a write to the pre-allocated slot.
numbers.push(i);
}
// Now length matches capacity.
assert_eq!(numbers.len(), 1000);
}
Don't trust amortized constant time when you care about latency. Pre-allocate.
The math behind the move
Without pre-allocation, a vector of size N performs roughly 2N element copies total due to the doubling strategy. The series of copies is N/2 + N/4 + N/8 ... which sums to N. Plus the initial writes, the total work is about 2N writes. With with_capacity(N), it performs zero copies during growth. You trade a single allocation for a series of allocations and copies.
The single allocation is almost always cheaper. Memory allocation involves system calls or complex heap management. Copying data involves touching memory, which can cause cache misses. If your elements are large, the copy cost dominates. If your elements are small, the allocation overhead dominates. Either way, pre-allocation wins.
There's a catch. If you over-allocate, you waste memory. If you reserve 1 million slots but only use 10, you hold 4MB of memory for u32. The vector won't give it back automatically. The memory stays reserved until the vector is dropped or you shrink it.
Convention aside: The community prefers with_capacity at the point of creation. If you need to grow later, reserve is the standard verb. It reads like natural language. "I need more room." Use reserve when you discover the size partway through. Use with_capacity when you know it at the start.
The hidden helper: collect()
You don't always need with_capacity. If you use an iterator, collect() often does the work for you. The collect method checks the iterator's size_hint. If the iterator knows its length, collect calls with_capacity internally. You get the performance without the boilerplate.
Many standard iterators provide accurate size hints. range, repeat, zip, and slices all report their length. If you chain operations, the hint propagates. filter might lose the hint because it doesn't know how many items pass. map preserves it.
/// Uses collect() to leverage automatic pre-allocation via size hints.
fn parse_ids(input: &str) -> Vec<u32> {
// split() knows the number of parts if the string is static.
// However, split() returns an iterator with a lower bound hint, not exact.
// collect() uses the lower bound, which might under-allocate.
// For exact allocation, count first or use with_capacity manually.
// This example shows collect() working with a range iterator
// which provides an exact size hint.
let squares: Vec<u64> = (0..1000)
.map(|x| x * x)
.collect();
// collect() called with_capacity(1000) automatically.
// The allocation happened once. No reallocations during the map.
assert_eq!(squares.capacity(), 1000);
squares
}
Let the standard library do the heavy lifting. collect() knows more than you think.
Realistic patterns: reserve and shrink
In real code, you often don't know the size at the start. You might read a file header to get a count. You might parse a JSON array and see the length. You might do a first pass to count items.
When you learn the size after the vector exists, use reserve. It adds to the current capacity. If the vector already has capacity, reserve only allocates if needed. It's safe to call multiple times.
/// Demonstrates reserve and shrink_to_fit in a realistic parsing scenario.
fn process_records(data: &[u8]) -> Vec<String> {
// First pass: count records to estimate capacity.
// This avoids multiple reallocations during the second pass.
let record_count = data.iter().filter(|&&b| b == b'\n').count();
// Reserve space for the estimated number of records.
// reserve() adds to current capacity, which is 0 here.
let mut records = Vec::with_capacity(record_count);
// Second pass: parse and collect.
for line in data.split(|&b| b == b'\n') {
if !line.is_empty() {
// Push the parsed string.
// Since we reserved capacity, this rarely triggers reallocation.
records.push(String::from_utf8_lossy(line).into_owned());
}
}
// If the estimate was too high, we might be holding excess memory.
// shrink_to_fit() releases the unused capacity back to the allocator.
// This is expensive because it may allocate a smaller block and copy.
// Only call this if the vector will live a long time.
records.shrink_to_fit();
records
}
Capacity is a promise to the allocator, not a guarantee of data. Check len(), not capacity, before indexing.
Pitfalls and edge cases
Over-allocation is the most common mistake. Developers see a performance tip and apply with_capacity everywhere. They pass a large constant hoping to cover all cases. The result is wasted memory. If you process many small vectors, the waste adds up. Memory pressure causes swapping. Swapping kills performance.
Profile before pre-allocating. Measure the allocation count and memory usage. If the vector is small, the cost of reallocation is negligible. The overhead of managing a large unused block might be worse.
Another pitfall is confusing capacity with length. with_capacity does not create elements. The vector is empty. Accessing by index panics. vec[0] crashes if len() is zero. You must push elements first. If you need a vector filled with a value, use vec![value; count]. It allocates and initializes in one step.
/// Shows the difference between with_capacity and vec! macro.
fn main() {
// with_capacity creates an empty vector with space.
let mut empty_but_big = Vec::with_capacity(10);
// empty_but_big[0] panics. Length is 0.
// vec! macro creates a vector with elements.
let filled = vec![0; 10];
// filled[0] is 0. Length is 10.
// This is equivalent to with_capacity followed by 10 pushes.
// Use vec! when you need initialized data immediately.
}
Match the allocation strategy to your knowledge of the data. Guessing is worse than doing nothing.
When to use what
Use Vec::with_capacity(n) when you know the exact count or a reliable upper bound before the loop starts. Use Vec::new() when the size is unpredictable or the vector is small enough that reallocation cost is lost in the noise. Use reserve(n) when you determine the required size partway through construction, such as after a first pass over the data. Use shrink_to_fit() when you pre-allocated a large buffer, filled only a fraction, and the vector will persist in memory for a long time. Use vec![value; n] when you need a vector initialized with a repeated value immediately. Use collect() when you can express the construction as an iterator pipeline; the standard library will pre-allocate if the iterator provides a size hint.