A Vec<T> is three machine words: a pointer to a heap buffer, a length,
and a capacity. Length is how many elements are actually there. Capacity
is how many the buffer has room for before it has to move. Everything in this
article follows from the gap between those two numbers.
let mut v: Vec<i32> = Vec::new(); // ptr dangling, len 0, cap 0 — no heap yet
v.push(1); // allocates
Vec::new() does not allocate. That is not an optimisation detail you can
ignore — it is why Vec::new() is const and why a struct full of empty
Vecs costs nothing until you fill it. The first push is where the heap
gets involved.
The doubling rule, in real numbers
When a push finds len == capacity, Vec asks for a bigger buffer, copies
the elements over, and frees the old one. It roughly doubles each time, with
a small floor for the first allocation. Here is a Vec<i32> taking 1000
pushes, measured with a counting global allocator:
Vec::new() + 1000 pushes -> 9 allocations, 8176 bytes requested
Vec::with_capacity(1000) + 1000 pushes -> 1 allocation, 4000 bytes requested
Nine allocations, because the capacities go 4, 8, 16, 32, 64, 128, 256, 512, 1024 — and $4 \times (4+8+\cdots+1024) = 8176$ bytes asked for across the sequence, to end up holding 4000 bytes of data. Every one of those steps also copied every element that already existed.
That copying is why push is amortised O(1) rather than plain O(1). Any
individual push may be O(n). But because the capacity doubles, a resize of
size $n$ only happens after $n/2$ cheap pushes have paid for it, so the total
work across $n$ pushes is $O(n)$ and the average is constant. The word
“amortised” is doing real work in that sentence: it is a statement about the
sum, not about any single call. If you are writing something latency
sensitive, a push can still spike.
💡Why double, rather than grow by a fixed amount like 1024 elements? click to reveal
Because a fixed increment makes the total quadratic. Growing by $k$ each time means you resize $n/k$ times, and resize number $i$ copies $ik$ elements, so the total copying is $k(1 + 2 + \cdots + n/k) \approx n^2/(2k)$ — quadratic in $n$, no matter how big you make $k$.
Doubling makes each resize’s cost proportional to the number of pushes since the last one, so the sum telescopes to $O(n)$. The price is up to 2× memory overshoot in the worst case, which is the trade every growable array in every language makes.
The number that surprises people
Here is the honest half of the story. The same experiment over one million pushes:
Vec::new() 1 000 000 pushes 0.843 ms
Vec::with_capacity(1e6) 1 000 000 pushes 0.716 ms
A 15% improvement. Not 10×. The allocation count collapses from twenty to one, and the elapsed time barely moves.
Both numbers are true and you should hold them together. Allocation count
collapsed because the doubling rule is exponential — twenty allocations
covers a million elements. And each of those twenty is a realloc, which a
modern allocator often satisfies by extending the existing mapping without
copying at all. Meanwhile the loop itself is memory-bound: writing four
megabytes to RAM costs what it costs, and that dominates.
So when is with_capacity worth reaching for?
- When the loop body is cheap and the count is known. 15% is 15%.
- When you are inside another loop, so the allocations multiply.
-
When the element type is expensive to move — a resize memcpy of
Vec<[u8; 4096]>is a very different cost fromVec<i32>. - When you care about peak memory, not throughput. Doubling can leave you holding nearly 2× what you need.
And when is it not? When you do not know the count, when you would have to
guess, and — most of all — when reaching for it turns a clear line into three
murky ones. with_capacity is a hint, not a correctness feature.
size_hint: the iterator protocol carries performance metadata
This is the first place you see that Rust’s Iterator trait carries more than
values. Every iterator has:
fn size_hint(&self) -> (usize, Option<usize>)
a lower bound and an optional upper bound on how many items remain. collect()
into a Vec calls it and pre-reserves the lower bound, which is why
v.iter().map(f).collect::<Vec<_>>() performs one allocation rather than
twenty. Measured:
[1, 2, 3].iter().size_hint() -> (3, Some(3)) exact
"a,b,c".split(',').size_hint() -> (0, None) no idea
A slice iterator knows exactly. str::split cannot know without scanning, so
it promises nothing — which means collecting a split into a Vec gets no
pre-sizing and grows the hard way. That is a small, concrete reason to prefer
split_once or a with_capacity when you are parsing a very large string,
and a good example of why “it’s just an iterator” hides real differences.
size_hint is a hint in the strict sense: it is not unsafe to get it
wrong, so no code may rely on it for memory safety. ExactSizeIterator is
the trait for iterators that promise an exact count.
💡v.iter().filter(|x| **x > 0).collect::<Vec<_>>() — what does filter's size_hint return, and how many allocations should you expect?
click to reveal
filter returns (0, Some(n)): it cannot know how many items will pass, so
the lower bound drops to zero while the upper bound stays at the source’s
length. collect reserves the lower bound, which is nothing, so the vector
grows by doubling as usual — around $\log_2 k$ allocations for $k$ survivors.
If you know most items survive, Vec::with_capacity(v.len()) plus extend
is measurably better. This is also why filter().count() is fine but
filter().collect() on a huge source deserves a thought.
Collections never shrink on their own
Removing elements does not give memory back. Measured:
let mut x = vec![1i32, 2, 3, 4, 5, 6, 7, 8];
before clear: len 8 cap 8
after clear(): len 0 cap 8
after shrink_to_fit(): len 0 cap 0
clear(), truncate(), pop(), remove(), drain() — none of them
reallocate. String, HashMap and VecDeque behave the same way. The buffer
stays exactly as big as the high-water mark until you explicitly call
shrink_to_fit() or shrink_to(n).
This is a deliberate design decision, and it cuts both ways.
It is a feature most of the time, because it is what makes buffer reuse work. The pattern
let mut buf = String::new();
for record in records {
buf.clear(); // keeps the capacity
render_into(&mut buf, record);
consume(&buf);
}
allocates a handful of times total instead of once per record, and it only
works because clear is not allowed to hand the buffer back. Writing
buf = String::new() inside the loop instead looks equivalent and throws the
whole benefit away.
It is a leak-shaped surprise the rest of the time. A Vec that briefly
held ten million elements holds forty megabytes forever, even at len() == 0,
unless you say otherwise. If a long-lived struct has a field that spikes,
shrink_to_fit() after the spike is the fix — and note that it may itself
allocate and copy, so it is not free either.
Three lints that catch the common mistakes
clippy -D warnings will stop you at three related shapes. All three messages
below are verbatim from clippy 0.1.95.
vec_init_then_push — “calls to push immediately after creation”:
let mut v = Vec::new();
v.push(1);
v.push(2); // just write vec![1, 2]
slow_vector_initialization — “slow zero-filling initialization”:
let mut v = Vec::with_capacity(n);
v.resize(n, 0); // write vec![0; n] — it can go straight to calloc
vec![0; n] is not sugar for a loop. For zeroable element types it can ask
the allocator for pre-zeroed pages, so the zeroing is effectively free rather
than a memset of $n$ bytes.
repeat_vec_with_capacity — “repeating Vec::with_capacity using
vec![x; n], which does not retain capacity”. This one is worth seeing
measured, because the failure is completely silent:
let nested = vec![Vec::<i32>::with_capacity(64); 3];
// capacities: [0, 0, 64]
vec![x; n] produces n - 1 clones plus the original. Cloning a Vec
gives you a vector whose capacity matches its length — and the length here
is zero. So two of the three reservations evaporate, and the third only
survives because it is the original value moved into the last slot. Every
reservation you thought you made is gone, and nothing warns you but the lint.
💡You need n empty vectors each pre-sized to 64. What do you write instead?
click to reveal
Build them one at a time so each one really allocates:
let nested: Vec<Vec<i32>> = (0..n).map(|_| Vec::with_capacity(64)).collect();
or std::iter::repeat_with(|| Vec::with_capacity(64)).take(n).collect().
The distinction is repeat (one value, cloned) versus repeat_with (a
closure, called each time) — the same distinction as unwrap_or versus
unwrap_or_else, which you will meet again in the error-handling track.
What to take away
- Length and capacity are different numbers, and only length is part of the public meaning of your data.
-
capacity()is an implementation detail. The standard library documents the amortised complexity and explicitly declines to guarantee the exact growth sequence — so never write a test that asserts on it. (That is precisely why this item is an article and not a graded problem.) -
with_capacityreliably collapses the allocation count, and moves wall-clock less than you expect. Measure before you clutter code with it. - Nothing shrinks unless you ask. Lean on that for buffer reuse; remember it when a long-lived structure is holding a spike.