C++ Iterators and Invalidation: Container Rules and Safe Erase Loops

Learn to read iterator ranges, match algorithms to categories, and mutate standard containers without using stale handles. Includes two fully traced erase examples.

KnowledgeGate Team

Exam prep & CS education

Updated 18 Sep 20266 min read

A C++ loop can compile, and its iterator can initially point to the expected value, yet an insert, erase, push_back, or rehash can silently make that position unusable. The symptom may be a skipped element, a wrong value, or undefined behaviour instead of a compiler error. The safe method is to mark the range, check the operations required, apply the exact container rule, and continue from the iterator returned by erase. The wider Coding & DSA catalogue provides more programming practice.

Related reading: Vector growth and capacity and STL algorithms.

C++ begin() and end(): read the half-open range first

A standard iterator range is written [first, last). first denotes the first element when the range is non-empty. last is the boundary one position past the final element, so it must never be dereferenced.

For std::vector<int> v{4, 7, 9, 12}, *v.begin() = 4, *(v.begin() + 2) = 9, and std::distance(v.begin(), v.end()) = 4. An ordinary loop visits 4, 7, 9, and 12, then increments once more until its iterator equals v.end(). It stops before another dereference. The sum is 4 + 7 + 9 + 12 = 32. For an empty vector, begin() == end(), so the body runs zero times. cbegin() and cend() express read-only traversal.

The iterator is the position-like object, while *it accesses its value. it + 2 works only for random-access iterators. Iterator categories determine which operations an iterator supports and which algorithms can use it. C++ Iterators Tutorial with Examples provides the broad traversal-and-algorithm foundation, including the baseline erase pattern. Mutation-time reasoning then depends on three additional tools: a container-by-container handle matrix, explicit capacity and rehash checks, and a full erase-loop ledger.

C++ iterator categories: operations determine which algorithms fit

An iterator category states the operations an algorithm may use, not whether an iterator survives mutation.

Category

Minimum useful operations

Standard-library examples

Input

Single-pass read and ++

Input stream iterator

Output

Single-pass write and ++

Output stream iterator

Forward

Multi-pass traversal and repeated ++

forward_list, unordered containers

Bidirectional

Forward operations plus --

list, map, set

Random access

Constant-time jumps, difference, and ordering

deque, vector

Contiguous

Random access plus adjacent storage

array, vector, basic_string

std::sort can order std::vector<int>{9, 1, 7, 3, 2, 8} as {1, 2, 3, 7, 8, 9} because vector iterators are random access. A std::list<int> with the same values must use list.sort() to get that order. std::distance can subtract vector endpoints but advances through all six list elements. Category controls movement and cost; invalidation controls whether a particular iterator remains usable.

C++ iterator invalidation rules: name the container and mutation

Identify the container and operation. Here, handles means iterators, pointers, and references.

Container

insert

erase

reserve or rehash

vector

Reallocation: all handles; otherwise, iterators and references at or after the point

Erased position and all iterators or references at or after it

Reallocation invalidates all handles

list / forward_list

Existing handles remain valid

Only erased handles

Not applicable

map / set

Existing handles remain valid

Only erased handles

Not applicable

unordered_map / unordered_set

Iterators only if rehash occurs; references and pointers survive

Only erased handles

All iterators; retained references and pointers survive

Start with v = {10, 20, 30, 40} and save old30 = v.begin() + 2. Calling next = v.erase(v.begin() + 1) removes 20 and gives {10, 30, 40}. next is valid at new index 1, with *next = 30. old30 is invalid because it followed the erased position, even if its old address looks plausible.

A rehash changes bucket arrangement, so unordered iterators are lost while retained references survive. Hashing and Collision Resolution explains the model.

Before-and-after trace of erasing 20 from vector [10,20,30,40], leaving [10,30,40] with old30 invalidated and next pointing at 30.

Erase while iterating in C++: use the returned position

Use the iterator returned by erase instead of incrementing a stale one:

cpp
std::vector<int> values{3, 4, 6, 7, 8, 10};

for (auto it = values.begin(); it != values.end(); ) {
    if (*it % 2 == 0) {
        it = values.erase(it);
    } else {
        ++it;
    }
}

Current value

Action

Sequence after action

New iterator target

3

Keep and increment

{3, 4, 6, 7, 8, 10}

4

4

Erase

{3, 6, 7, 8, 10}

6

6

Erase

{3, 7, 8, 10}

7

7

Keep and increment

{3, 7, 8, 10}

8

8

Erase

{3, 7, 10}

10

10

Erase

{3, 7}

end

There is no unconditional header increment. erase supplies the next valid position; only the keeping branch advances explicitly. This avoids both stale access and skipped successors. Four values are removed, leaving size 2 and checksum 3 + 7 = 10. This shape works for containers whose member erase(it) returns the following iterator, including list and map. forward_list instead uses a predecessor with erase_after.

Six-frame trace of a safe erase loop over vector [3,4,6,7,8,10] removing even values down to [3,7], with four removed and checksum 10.

Safer C++ mutation patterns: reserve, erase_if, and reacquire

When only the filtered result matters, C++20 provides a whole-container operation:

cpp
std::erase_if(values, [](int x) { return x % 2 == 0; });

For {3, 4, 6, 7, 8, 10}, it returns removal count 4 and leaves {3, 7}.

Capacity planning can protect iterators captured afterwards. Start with std::vector<int> v{10, 20, 30};, record old_capacity = v.capacity(), call v.reserve(old_capacity + 5), and only then capture it20 = v.begin() + 1. A subsequent v.push_back(40) cannot exceed the requested capacity, so it20 remains valid and denotes 20. The old past-the-end iterator does not remain valid. Any iterator captured before a reallocating reserve is lost.

Two robust designs are to retain a stable key or meaningful index and reacquire after mutation, or to separate selection from mutation into two phases. For unordered containers, reserve before retaining iterators. A later rehash can still invalidate them.

C++ invalidation traps: skipped values, stale end(), and plausible addresses

Code smell

What goes wrong

Correction

Extra ++it after it = erase(it)

Returned element skipped

Increment only without erase

erase(it++) on vector

Mutation invalidates affected position

Assign the returned iterator

Cached vector v.end()

Boundary stale

Re-evaluate v.end()

Dereferencing end()

Boundary is not an element

Stop before dereference

Vector handles across possible reallocation

All become invalid

Reserve first or reacquire

Unordered iterator after rehash

Iterator fails; retained references survive

Track types separately

From {2, 4, 6}, erase 2 returns 4. An extra increment jumps to 6, skipping 4. The correct loop tests 4 and 6, finishing empty.

Name the container, mutation, and position; check capacity or buckets; mark affected handles and cached end(); then trace valid handles. Debug modes and sanitizers help but cannot legalise undefined behaviour.

C++ iterator questions: trace validity before tracing values

Three useful assessment patterns are: identify the minimum category an algorithm requires, decide which handles survive a stated mutation, and repair an erase loop with its exact output. If a stale iterator makes the program undefined, “what does it print?” has no dependable value to guess.

Check A: Start with vector {1, 2, 3, 4, 5}, save an iterator to 4, then erase 2. The result is {1, 3, 4, 5}. The returned iterator points to 3, while the saved iterator to 4 is invalid because it followed the erased position.

Check B: Start with list {1, 2, 3}, save an iterator to 2, then insert 9 immediately before it. The result is {1, 9, 2, 3}, and the saved iterator remains valid at 2.

General vector construction, access, sorting, and resizing belong in Vector in C++: Create, Iterate and Sort with Examples. The narrow question after a mutation is which saved iterator, pointer, reference, or past-the-end boundary remains usable.

C++ iterators and invalidation: the short version and next step

Use four steps: mark [begin, end), identify the required iterator category, apply the exact mutation rule for that container, and continue from the iterator returned by erase. The main trace is {3, 4, 6, 7, 8, 10} -> {3, 7}, with removed = 4 and checksum 10.

Now carefully reproduce both traces: erase 20 from {10, 20, 30, 40} and cross out old30, then filter the six-value vector without skipping. For broader language and STL study, continue with C++ Programming.