Skip to content
← Back to explainers

Rust explainer

Capacity, amortisation, and reusing Rust buffers

About 7 min to read. Allow extra time to work through examples.

Learn when a Vec grows, what amortised O(1) means, and how to reuse its buffer without retaining more memory than you need.

This article uses Rust. You can make it your practice mode to browse related content across the site.

A Vec<T> stores a pointer, a length, and a capacity. Length counts the initialized elements you can use. Capacity tells you how many elements fit before a push needs to grow the backing allocation.

let mut values: Vec<i32> = Vec::new();
assert_eq!(values.len(), 0);
assert_eq!(values.capacity(), 0);
// A non-zero-sized element needs backing storage.
values.push(1);

An empty vector still occupies space for its metadata, but Vec::new() does not allocate a heap buffer. Zero-sized types are a special case: a Vec<()> can hold elements without allocating storage for them.

Growth: a guarantee and an observation

For push, a vector with spare capacity does not reallocate. When its buffer is full, growing it may move the elements to a new allocation. An allocator can also grow the existing allocation in place, so growth does not mean every element was necessarily copied.

Rust guarantees amortised O(1) push, but does not promise a particular growth factor. Here is a small experiment you can repeat:

let mut values: Vec<i32> = Vec::new();
let mut capacities = Vec::new();
for value in 0..1_000 {
    let before = values.capacity();
    values.push(value);
    if values.capacity() != before {
        capacities.push(values.capacity());
    }
}
println!("{capacities:?}");

For example, running this on macOS with Rust 1.98.1 produced:

[4, 8, 16, 32, 64, 128, 256, 512, 1024]

That is nine observed capacity changes, not a portable promise of nine heap allocations or nine copies. You may see a different sequence with another implementation or element type. Test the capacity guarantees your program needs, rather than asserting this exact sequence.

Why amortised O(1) still allows a slow push

Imagine starting with room for one element and doubling whenever the vector fills up. If growth moves the existing elements, the numbers moved are 1, 2, 4, 8, and so on. Across n pushes, that sum is less than 2n. Adding the n new elements also takes O(n) work, so the total is O(n).

Amortised O(1) means constant work per push when spread across the whole sequence. It does not mean every push is equally fast: one growth can move O(n) elements. This model counts element moves for a fixed element type; it is not a bound on allocator latency or elapsed time.

Check your understanding Why not grow by a fixed number of elements each time? Show answer

In the model where each growth copies the existing elements, adding a fixed k slots means roughly n/k growths. The total copying is proportional to $k(1 + 2 + \cdots + n/k)$, or O(n²) for fixed k.

Geometric growth avoids that total cost by keeping spare capacity. Doubling is one possible factor; it is not a rule shared by every growable array.

If you know the size in advance, reserve enough room:

let mut values: Vec<i32> = Vec::with_capacity(1_000);
for value in 0..1_000 {
    values.push(value);
}
assert_eq!(values.len(), 1_000);
assert!(values.capacity() >= 1_000);

These pushes do not need to grow the buffer. That can reduce allocator work and avoid growth pauses, but it does not guarantee a particular speedup. Timing depends on the allocator, element size, loop body, compiler and machine. Measure the workload you care about with optimized code and repeated runs.

For an existing vector, reserve(additional) reserves space for that many more elements beyond its length, not beyond its current capacity. For example, a vector with length 3 calling reserve(10) must have capacity for at least 13 elements afterward. It may already have enough room, and it may reserve more than requested.

Reserving a guessed upper bound can waste memory. A large reservation can also fail earlier than incremental growth. with_capacity is useful when the size estimate and memory trade-off make sense, not a universal rule.

Iterator size hints help plan capacity

Every iterator provides:

fn size_hint(&self) -> (usize, Option<usize>)

The pair is a lower bound and an optional upper bound on the number of items remaining. A slice iterator knows its exact remaining length. A string split generally cannot know its item count without scanning the string.

collect::<Vec<_>>() can use size information and specialized implementations to plan capacity. That often avoids repeated growth for a slice iterator, but neither the iterator contract nor collect promises one allocation for every pipeline. Some consuming iterators can even reuse an existing buffer.

Check your understanding What happens to the size hint after filtering a slice iterator? Show answer

For a source with n items remaining, filter reports (0, Some(n)): every item might fail the predicate, or every item might pass. It cannot promise an exact output size.

If you know most elements survive, you can explicitly reserve room for n outputs before extending the vector. That trades potentially unused memory for less growth. Benchmark it before assuming it is faster.

A size hint must be correct, but unsafe code cannot trust an ordinary iterator's hint for memory safety. ExactSizeIterator adds an exact-count contract; it is also a safe trait, not permission to skip safety checks.

Clearing keeps the vector's buffer

Vec does not automatically shrink when you remove elements. clear() drops the elements and sets the length to zero while retaining capacity:

let mut v = vec![1, 2, 3];
let capacity = v.capacity();
v.clear();
assert!(v.is_empty());
assert_eq!(v.capacity(), capacity);
v.shrink_to_fit();

clear() can still release resources owned by the elements. For example, clearing a Vec<String> drops its strings and their buffers while retaining the outer vector's allocation. “Keeps capacity” does not mean “frees nothing.”

shrink_to_fit() asks to discard spare capacity. shrink_to(n) asks to shrink while keeping room for at least n elements and all existing elements. If capacity is already below n, it does nothing: it never grows the buffer. Either method may leave extra capacity, depending on the allocator.

let mut values = Vec::with_capacity(16);
values.extend([1, 2, 3]);
let before = values.capacity();
values.shrink_to(before + 10);
assert_eq!(values.capacity(), before);
values.shrink_to(4);
assert!(values.capacity() >= 4);
assert_eq!(values, [1, 2, 3]);

Shrinking may move elements. Freeing an allocation also does not guarantee that the process's memory usage immediately drops in an operating-system monitor.

Reusing a buffer can be useful for repeated work:

let mut buffer = String::new();
for record in ["first", "second", "third"] {
    buffer.clear();
    buffer.push_str(record);
    println!("{buffer}");
}

String::clear likewise retains its backing capacity. The buffer can grow for a larger record and serve later smaller ones. Replacing it with a new string each iteration would discard that reuse.

A long-lived buffer can retain a one-off large allocation. Consider shrinking after such a spike if it is unlikely to recur; shrinking after every record can undo the reason to reuse the buffer. These are guarantees about the specific collection, not a claim that all Rust collections retain memory: node-based collections have different allocation behavior.

A reservation trap with nested vectors

This expression repeats a value by cloning it:

let nested = vec![Vec::<i32>::with_capacity(64); 3];
assert!(nested.iter().all(Vec::is_empty));

Cloning a vector preserves its elements, not a promise to preserve spare capacity. The clones of this empty vector therefore need not retain the reservation. Clippy's repeat_vec_with_capacity lint calls out this pattern.

Check your understanding How do you reserve room for 64 elements in each of n empty vectors? Show answer

Call the constructor separately for each vector:

let n = 3;
let nested: Vec<Vec<i32>> = (0..n)
    .map(|_| Vec::with_capacity(64))
    .collect();
assert!(nested.iter().all(|v| v.is_empty() && v.capacity() >= 64));

They are still empty: capacity is reserved room, not initialized elements. Use vec![0; 64] for a vector that actually contains 64 zeroes.

Clippy can also suggest vec![a, b] instead of several immediate pushes, or vec![0; n] instead of reserving then resizing with zeroes. These express your intent directly; they do not promise free initialization or a specific allocator implementation.

Choosing what to do

  • Length counts initialized elements; capacity is a documented usable bound.
  • Amortised O(1) push is guaranteed. An exact growth sequence is not.
  • Reserve when the expected size justifies it, and measure performance claims.
  • Clearing a vector drops its elements but retains its own buffer. Shrink deliberately when retaining that space no longer helps.

For the precise contracts, see the standard library's Vec guarantees and shrinking documentation.

step 2 of 21

Collections, Text and First Iterators

Up next Build a CSV record with one output buffer