Performance[perf]

std::vector in C++: size, capacity, erase and reallocation

Section
Performance [perf]
Standard
C++17
Compilers
MSVC 19.44, Clang 19.1 (MSVC STL), GCC 16.2, Clang 23.1 (libc++)
Published
Length
12 min read
In this answer9 clauses
  1. 1How a std::vector grows: size and capacity
  2. 2What a reallocation costs, and what reserve saves
  3. 3When a growing std::vector copies instead of moving
  4. 4What each std::vector member invalidates
  5. 5The std::vector member for each job
  6. 6Remove elements: std::erase, std::erase_if and the erase-remove idiom
  7. 7push_back or emplace_back
  8. 8v[i] or at(), braces, and std::vector<bool>
  9. 9Passing a vector, and when not to use one

Abstract

std::vector<T> (any T but bool) keeps its elements in one contiguous block: size() is how many it holds, capacity() how many fit before it must reallocate. A push_back on a full vector allocates a larger block and moves or copies every element into it, which invalidates all iterators, pointers and references into the vector. Call reserve(n) when you know the count, give the element type a noexcept move constructor, and remove elements with std::erase or std::erase_if (C++20).

A row of eight cells: the first five hatched, the last three empty, a blue line at the boundary. Beneath, one dimension line spans the hatched cells, a longer one the whole row.
Figure 1 — size() is how many elements a vector holds, capacity() how many its block has room for.

std::vector<T> is C++’s growable array: for any T but bool, its elements sit in one contiguous block, size() is how many it holds and capacity() how many fit. push_back is amortized constant time, but on a full vector it allocates a larger block, moves or copies every element into it and frees the old one, invalidating every iterator, pointer and reference into the vector. Call reserve(n) when you know the count, give the element type a noexcept move constructor so growth moves instead of copying, and remove elements with std::erase or std::erase_if (C++20).

This answer is written for C++17. What came after:

  • C++20 adds std::erase, std::erase_if and the signed std::ssize, and makes the members constexpr.
  • C++23 adds std::ranges::contains, the members append_range, insert_range and assign_range, and the std::from_range constructor; all four compilers below accepted contains, append_range and from_range in C++23 mode, MSVC with /std:c++23preview.
  • C++26 adds std::inplace_vector, a vector of fixed capacity (its first releases), and makes the preconditions of operator[], front, back and pop_back hardened.

Four compilers on three standard libraries built the samples, in C++17 mode unless a line names a later standard: MSVC 19.44 and Clang 19.1 on Microsoft’s STL (Windows x64), GCC 16.2 with libstdc++ and Clang 23.1 with libc++ (Compiler Explorer, Linux x86-64). Nothing was timed: the samples count capacities, allocations, copies and moves, and the growth, reserve(1000), copy-or-move and emplace_back counts were the same built without optimization and with -O2 (/O2 on MSVC).

What changed: elements are removed with v.erase(std::remove(v.begin(), v.end(), x), v.end()) std::erase(v, x).

since C++20

How a std::vector grows: size and capacity

This loop prints the capacity each time it changes over 1,000 push_backs of a sensor sample:

[Example 1:
struct Sample {
    int sensor;
    double value;
};
// ...
std::vector<Sample> samples;
std::size_t capacity = samples.capacity();
std::printf("%zu", capacity);  // 0: no block yet
for (int i = 0; i < 1000; ++i) {
    samples.push_back({i, 20.5});
    if (samples.capacity() != capacity) {  // a new block
        capacity = samples.capacity();
        std::printf(" %zu", capacity);
    }
}

— end example]

On Microsoft’s STL, with MSVC and with Clang 19.1, it printed:

[Example 2:
0 1 2 3 4 6 9 13 19 28 42 63 94 141 211 316 474 711 1066
size 1000, capacity 1066

— end example]

On libstdc++ (GCC 16.2) and libc++ (Clang 23.1):

[Example 3:
0 1 2 4 8 16 32 64 128 256 512 1024
size 1000, capacity 1024

— end example]

The standard requires push_back to take amortized constant time ([sequence.reqmts]) and names no growth factor: the sequences are each library’s. With one push_back at a time, Microsoft’s STL added half the capacity (at least one slot) and the other two doubled it.

libstdc++ and libc++ part when one call adds several elements: an insert of 9 into 8 elements with a capacity of 16 left a capacity of 17 on libstdc++, 32 on libc++ and 24 on Microsoft’s STL. libstdc++ sizes the new block from size(), libc++ from capacity().

Each new block is a reallocation, and a pointer taken before it still holds the old address:

Top: a full block of four hatched cells, pointer p on the second. After push_back the old block is grey, released, p still on it; the four move into a larger block that gains a fifth.
Figure 2 — A push_back on a full vector moves or copies every element into a larger block and releases the old one, so pointers into the old block are invalid.

What a reallocation costs, and what reserve saves

Tracked wraps an int, and its copy constructor and noexcept move constructor count their calls; 1,000 of them were pushed without and with a reserve(1000) first:

[Example 4:
std::vector<Tracked> samples;
if (reserve_first) {
    samples.reserve(1000);
    ++allocations;
}
for (int i = 0; i < 1000; ++i) {
    std::size_t before = samples.capacity();
    samples.push_back(Tracked(i));
    if (samples.capacity() != before)
        ++allocations;
}

— end example]

A second program called samples.reserve(samples.size() + 1) before each push_back. The counts, where a block is a change of capacity():

1,000 push_backs Blocks Moves Copies
Microsoft’s STL, no reserve 18 3,137 0
libstdc++ and libc++, no reserve 11 2,023 0
All three, reserve(1000) first 1 1,000 0
All three, reserve(size() + 1) before each 1,000 500,500 0

In every row, 1,000 moves are the Tracked(i) temporaries moved into place. The rest is growth, where each reallocation moved the old capacity’s worth: 1 + 2 + 4 + … + 512 = 1,023 on libstdc++ and libc++, 1 + 2 + 3 + 4 + 6 + … + 711 = 2,137 on Microsoft’s STL. A reserve before every push_back is the misuse cppreference’s reserve page warns about: the capacity grows linearly, and here 1,000 allocations moved 499,500 elements. For the same reason, a function that receives a vector by reference should usually not call reserve on it.

reserve also buys stable addresses: after it, no insertion reallocates until the size would pass capacity() ([vector.capacity]), so until then a push_back invalidates no pointer or reference to an element already there.

clang-tidy’s performance-inefficient-vector-operation (19.1.5, from Visual Studio) flags a plain push_back loop with no reserve before it; clang-tidy and the other analyzers are on Toolchain.

When a growing std::vector copies instead of moving

When push_back reallocated, all three libraries moved the elements if their move constructor was noexcept or they could not be copied, and copied them otherwise. The reason is a promise in [vector.modifiers]: if an exception is thrown while inserting a single element at the end “and T is Cpp17CopyInsertable or is_nothrow_move_constructible_v<T> is true, there are no effects.” Four element types were pushed 1,000 times each; the first two differ by one word:

[Example 5:
struct NothrowMove {
    NothrowMove() = default;
    NothrowMove(const NothrowMove&) { ++copies; }
    NothrowMove(NothrowMove&&) noexcept { ++moves; }
};
struct ThrowingMove {
    ThrowingMove() = default;
    ThrowingMove(const ThrowingMove&) { ++copies; }
    ThrowingMove(ThrowingMove&&) { ++moves; }  // may throw
};

— end example]

What push_back growth did, counted as the totals without reserve minus those with reserve(1000):

Element type Microsoft’s STL libstdc++ libc++
noexcept move moved 2,137 moved 1,023 moved 1,023
Copyable, move may throw copied 2,137 copied 1,023 copied 1,023
Copy only, no move constructor copied 2,137 copied 1,023 copied 1,023
Move only, move may throw moved 2,137 moved 1,023 moved 1,023

A move-only type is moved even when its move may throw; if that move throws, the standard leaves the effects unspecified. The promise covers a single element at the end. Each call below reallocated a vector of 8 ThrowingMove elements in a block of 8 (16 for shrink_to_fit), and the counts include the new elements’ own: the move of T(), the four copies of t.

Call Microsoft’s STL libstdc++ and libc++
push_back(T()) copied 8, moved 1 copied 8, moved 1
insert(begin() + 4, T()) copied 0, moved 9 copied 8, moved 1
emplace(begin() + 4) copied 0, moved 8 copied 8, moved 0
insert(begin(), 4, t) copied 4, moved 8 copied 12, moved 0
reserve(16), resize(12), shrink_to_fit() copied 8 copied 8

An insert or emplace before the end promises less, and there Microsoft’s STL moved the elements anyway. Mark the move constructor noexcept when it cannot throw: when to mark a move constructor noexcept has the rules and the static_assert that checks it.

What each std::vector member invalidates

A call that reallocates invalidates every iterator, pointer and reference into the vector, end() included. This program shows the block changing under a push_back; it compares data() as an integer and never reads through an old pointer:

[Example 6:
// The block's address as a number: compared, never dereferenced.
std::uintptr_t block(const std::vector<Sample>& samples) {
    return reinterpret_cast<std::uintptr_t>(samples.data());
}
// ...
std::uintptr_t before = block(samples);
samples.push_back({4, 21.0});  // full: a new block
std::printf("push_back on a full vector: %s, capacity %zu\n",
            block(samples) == before ? "same block" : "new block",
            samples.capacity());
before = block(samples);
samples.push_back({5, 21.5});  // room left

— end example]

From four elements in a capacity of 4, libstdc++ and libc++ printed this; Microsoft’s STL printed a capacity of 6 on both lines:

[Example 7:
size 4, capacity 4
push_back on a full vector: new block, capacity 8
push_back with room left:   same block, capacity 8

— end example]

The same test on the other members, each from 8 elements in a capacity of 16, beside the complexity and invalidation rules of the standard and cppreference’s std::vector page:

Member Complexity New block, all three Invalidates
push_back, emplace_back amortized constant when full all if it reallocates, otherwise end()
insert, emplace linear in the elements inserted plus the distance to the end; in the new size if it reallocates when the new size passes the capacity all if it reallocates, otherwise those at or after the insertion point, end() included
erase linear in the distance to the end no those at or after the erase point, and end()
pop_back constant no the last element and end()
resize(n) linear in the change of size, plus any reallocation when n passes the capacity all if the capacity changed, otherwise end() and the erased elements
reserve(n) linear in the size when n passes the capacity all if it reallocates, otherwise none
clear linear no; the capacity stays all, always
assign linear in the count when the count passes the capacity all, always
shrink_to_fit linear in the size, if it reallocates yes, from 8 in 16 all if it reallocates, otherwise none

The std::vector member for each job

Job Call Since
Length v.size(), or std::ssize(v) for a signed count size() before C++17, std::ssize C++20
Is it empty v.empty() before C++17
Add at the end v.push_back(x), v.emplace_back(args) before C++17
Add a range at the end v.append_range(r) C++23
Insert at a position v.insert(v.begin() + i, x) before C++17
First and last element v.front(), v.back(), on a non-empty vector before C++17
Remove the table under “Remove elements” below before C++17; std::erase C++20
Find a value std::find(v.begin(), v.end(), x), then compare with v.end() before C++17
Find by a member std::find_if with a lambda, or std::ranges::find(v, x, &T::member) std::ranges::find C++20
Contains std::ranges::contains(v, x) C++23
Sort std::sort(v.begin(), v.end()), or with a comparator for std::sort before C++17
Change the size v.resize(n) appends value-initialized elements (0 for numbers); v.resize(n, x) appends copies of x before C++17
Make room, same size v.reserve(n) before C++17

std::find, std::find_if, std::sort, std::remove and std::ranges::contains are in <algorithm>; std::erase and std::erase_if for a vector are in <vector>. Finding a sample by its sensor:

[Example 8:
auto it = std::find_if(
    samples.begin(), samples.end(),
    [](const Sample& s) { return s.sensor == 4; });
if (it != samples.end())
    std::cout << "sensor 4: " << it->value << '\n';
// ...
auto found = std::ranges::find(samples, 2, &Sample::sensor);  // C++20

— end example]

It printed sensor 4: 21 and, in C++20 mode, sensor 2: 19.5 on all four. Find, sort, insert, resize and the ends on four readings:

[Example 9:
std::vector<double> readings{3.5, 1.5, 2.5, 4.0};
std::printf("size: %zu\n", readings.size());  // the length
auto it = std::find(readings.begin(), readings.end(), 2.5);
if (it != readings.end())
    std::printf("found 2.5 at index %td\n", it - readings.begin());
std::sort(readings.begin(), readings.end());
print("sort:", readings);
readings.insert(readings.begin() + 1, 2.0);
print("insert:", readings);
readings.resize(7);  // new elements are 0.0
print("resize(7):", readings);
std::printf("front %.1f, back %.1f\n", readings.front(),
            readings.back());
readings.pop_back();
print("pop_back:", readings);

— end example]

All four printed:

[Example 10:
size: 4
found 2.5 at index 2
sort:        1.5 2.5 3.5 4.0
insert:      1.5 2.0 2.5 3.5 4.0
resize(7):   1.5 2.0 2.5 3.5 4.0 0.0 0.0
front 1.5, back 0.0
pop_back:    1.5 2.0 2.5 3.5 4.0 0.0

— end example]

Remove elements: std::erase, std::erase_if and the erase-remove idiom

Call Removes Returns
v.erase(it) the element at it the iterator after it
v.erase(first, last) the elements from first up to last the iterator after the last one erased
std::erase(v, x), C++20 each element equal to x how many it removed
std::erase_if(v, pred), C++20 each element pred accepts how many it removed
v.pop_back() the last element, on a non-empty vector nothing
v.clear() every element nothing

Because erase returns the next position, a loop that erases as it goes continues from it:

[Example 11:
for (auto it = samples.begin(); it != samples.end();) {
    if (it->value < 0.0)         // a failed reading
        it = samples.erase(it);  // the element after it
    else
        ++it;
}

— end example]

It kept sensors 1 and 4 of five on all four compilers. Each erase assigns every later element one place forward ([vector.modifiers]), so the loop is quadratic in the worst case where std::erase_if is one pass: keep the loop for when each erased element needs work of its own. The C++20 calls:

[Example 12:
std::vector<double> readings{1.5, 0.0, 2.5, 0.0, 4.5, 3.0};
std::size_t dropped = std::erase(readings, 0.0);  // C++20
std::size_t hot = std::erase_if(readings,         // C++20
                                [](double r) { return r > 4.0; });

— end example]

They returned 2 and 1 and left 1.5 2.5 3.0 on all four. In C++17 mode all four rejected both (GCC: 'erase' is not a member of 'std'), and the erase-remove idiom does the same in two steps:

[Example 13:
auto first = std::remove(readings.begin(), readings.end(), 0.0);
auto dropped = readings.end() - first;  // how many go
readings.erase(first, readings.end());
readings.erase(std::remove_if(readings.begin(), readings.end(),
                              [](double r) { return r > 4.0; }),
               readings.end());

— end example]

It left the same three readings.

push_back or emplace_back

emplace_back builds the element in place from constructor arguments; push_back takes a finished one. A Tracked built from a sensor id and a reading, counting its copies and moves, was added six ways to a vector with room reserved. All four printed:

[Example 14:
push_back(s)                    copies 1, moves 0
emplace_back(s)                 copies 1, moves 0
push_back(Tracked(2, 21.0))     copies 0, moves 1
emplace_back(Tracked(2, 21.0))  copies 0, moves 1
push_back({3, 21.5})            copies 0, moves 1
emplace_back(3, 21.5)           copies 0, moves 0

— end example]

For an lvalue and for a temporary the two do the same work; emplace_back saves the move only when it receives the constructor’s arguments. clang-tidy 19.1.5’s modernize-use-emplace flagged the three calls that pass a Tracked or braces, and --fix rewrote each to pass the constructor’s arguments.

  • emplace_back returns a reference to the new element since C++17: double& added = readings.emplace_back(2.5); built in C++17 mode on all four and failed in C++14 mode.
  • On an aggregate such as Sample, which declares no constructor, samples.emplace_back(2, 21.0) needs C++20, which initializes aggregates from parentheses (P0960R3). All four rejected it in C++17 mode (MSVC: C2661: 'Sample::Sample': no overloaded function takes 2 arguments); samples.push_back({1, 20.5}) builds in both.

emplace_back also calls explicit constructors, which push_back does not:

[Example 15:
std::vector<std::vector<double>> rows;
rows.emplace_back(10);  // calls the explicit vector(size_type)

— end example]

All four printed rows: 1, rows[0].size(): 10: one row of ten elements, where rows.push_back(10) does not compile on any of them. Only MSVC’s note says why: the constructor “is declared ‘explicit’”.

v[i] or at(), braces, and std::vector<bool>

v[i] with i >= size() is undefined behavior through C++23. C++26 makes n < size() a hardened precondition of operator[] (P3471R4): a contract violation on a hardened implementation, undefined behavior otherwise. front(), back() and pop_back() on an empty vector are undefined behavior too, and C++26 makes “a.empty() is false” a hardened precondition of each. None of the three libraries tested defines __cpp_lib_hardened_vector, even in C++26 mode; the switches that turn on each library’s own checks are tested, on a span, in does std::span check bounds.

at(i) throws std::out_of_range when i >= size(), with each library’s own what(), so catch the type:

Library what()
Microsoft’s STL invalid vector subscript
libstdc++ vector::_M_range_check: __n (which is 4) >= this->size() (which is 4)
libc++ vector

Declarations that read alike, and what std::vector<bool> does differently:

Declaration What it makes
std::vector<int> a(5, 2); five elements, each 2
std::vector<int> b{5, 2}; two elements, 5 and 2
std::vector c{1, 2, 3}; a std::vector<int>, deduced (C++17)
std::vector d{c}; a copy of c, not a vector of vectors
std::vector e{c, c}; a std::vector<std::vector<int>>
auto bit = flags[0]; on a std::vector<bool> a proxy: bit = true; changes flags[0]
flags.data() an error: std::vector<bool> has no data()
bool* p = &flags[0]; an error: flags[0] is not a bool

All four agreed on every row. b differs from a because list-initialization tries the std::initializer_list constructors first ([over.match.list]). The standard does not require std::vector<bool> to store an array of bool and recommends bits instead ([vector.bool.pspc]); libstdc++ declares void data() = delete;, and the other two libraries have no data() at all.

Passing a vector, and when not to use one

The Core Guidelines’ SL.con.2 makes std::vector the default container “unless you have a reason to use a different container”. The reasons:

You need Use
A read-only parameter std::span<const T> (C++20) or const std::vector<T>&
To return a sequence std::vector<T>, by value
A size fixed at compile time std::array<T, N>
References that survive growth at either end std::deque<T>
A fixed capacity and no heap std::inplace_vector<T, N> (C++26)
Lookup by key a std::map or one of its alternatives
Anything else std::vector<T>

Why a read-only parameter is a reference and a returned vector a value is in pass by value or const reference. std::inplace_vector never grows past its capacity:

[Example 16:
std::inplace_vector<double, 4> window;  // C++26: 4 slots, no heap
for (double r : {3.5, 1.5, 2.5, 4.0})
    window.push_back(r);
if (!window.try_push_back(3.0))  // full: no throw
    std::printf("try_push_back: full\n");
try {
    window.push_back(3.0);  // full: throws
} catch (const std::bad_alloc&) {
    std::printf("push_back: std::bad_alloc\n");
}

— end example]

GCC 16.2 in C++26 mode printed both lines. MSVC 19.44, Clang 19.1 on Microsoft’s STL and Clang 23.1 with libc++ have no <inplace_vector> (MSVC: fatal error C1083).

Before you ship code that grows a vector:

  1. reserve(n) once, before the loop, when you know n; not reserve(size() + 1) before each push_back.
  2. A noexcept move constructor on a copyable element type, or each reallocation by push_back, emplace_back, reserve or resize copies it.
  3. After a call that may reallocate, take iterators, pointers and references again, and after an insert or erase those at or after its position; in an erase loop, it = v.erase(it).
  4. std::erase or std::erase_if (C++20) to remove by value or by condition; the erase-remove idiom before C++20.
  5. at(i) where an index may be out of range: v[i] there is undefined behavior.

More answers about what code costs are in Performance.

Questions

How do I get the length of a std::vector?

Call v.size(), the number of elements the vector holds; std::size(v) returns the same. For a signed count, C++20 adds std::ssize(v), which all four compilers tested rejected in C++17 mode. capacity() is something else: how many elements the current block can hold before the vector reallocates.

How do I find an element in a std::vector?

std::find(v.begin(), v.end(), x) returns an iterator to the first match, or v.end() when there is none, so compare it with v.end() before using it. For a yes-or-no answer, C++23 adds std::ranges::contains(v, x), which all four compilers tested accepted in C++23 mode and rejected in C++20 mode. On a sorted vector, std::binary_search answers the same question.

How do I sort a std::vector?

Call std::sort(v.begin(), v.end()), which sorts in ascending order; C++20 adds std::ranges::sort(v), which takes the vector itself. For descending order, pass a comparator as the last argument: std::sort(v.begin(), v.end(), std::greater<>()), which all four compilers tested built and ran. Any other order takes a comparator of your own.

Does std::vector shrink when I erase elements?

No. erase, clear() and a resize to fewer elements destroy elements and keep the capacity. shrink_to_fit() asks for the capacity to be cut to the size, a request the standard calls non-binding; on Microsoft’s STL, libstdc++ and libc++ it was honored, and a vector cut from 100 elements to 10 went from a capacity of 141 (Microsoft’s) or 128 (the other two) to 10.

What is the difference between resize and reserve in std::vector?

resize(n) changes the size: it destroys the elements past n, or appends value-initialized ones (0.0 for a vector of double; resize(n, x) appends copies of x). reserve(n) changes only the capacity: it allocates a block for at least n elements when the vector has less room, and adds no element. Index only below size(), whatever reserve made room for.

Why is std::vector<bool> special?

The standard does not require std::vector<bool> to store an array of bool and recommends a space-optimized representation of bits instead. Its operator[] returns a proxy object, so after auto b = v[0]; the assignment b = true changes v[0]. It has no data(), and bool* p = &v[0] does not compile; all four compilers tested rejected both. Two threads writing two different elements can race, which no other std::vector allows.

Read next

  1. When to mark a move constructor noexcept in C++Performance · Miriam Castellanos · written for C++20
  2. C++ map: std::map find, insert, erase and iterateStandard Library · Miriam Castellanos · written for C++17
  3. C++ pass by value or const reference: the rule since C++17Performance · Miriam Castellanos · written for C++20