AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

13 STL containers / Lesson 6 of 6

Iterators and invalidation — the contract behind every loop

14 min · +10 XP · 2 exercises · 7 questions

Read the text14 min
Exercises0/2
Quiz7 q

Quick answer

What is iterator invalidation in C++?

Iterator invalidation is when an operation on a container leaves an existing iterator, pointer or reference aimed at an element that has moved or been destroyed; using it is undefined behaviour. A std::vector that reallocates invalidates everything, and an insert or erase invalidates from that point onwards. Node-based containers — std::list, std::set, std::map — keep an iterator valid until its own element is erased.

An iterator is a generalised pointer: something you can dereference to reach an element and increment to reach the next one. Every container hands them out through begin() and end(), every algorithm in <algorithm> takes a pair of them, and the range-based for of Module 3 is sugar over them. What makes them more than pointers is that each container promises a category — how far and in which directions its iterators can move — and an invalidation rule — which operations on the container leave existing iterators pointing at garbage. This lesson covers the categories and what they cost, the helper functions std::next, std::prev, std::distance and std::advance, const_iterator and reverse iterators, the invalidation table, the one correct way to erase while iterating, and iterator ranges as the interface algorithms share.

Categories

CategoryCan doContainers
input / outputread or write once, move forwardstreams (std::istream_iterator)
forwardread/write, ++, multi-passstd::forward_list, the unordered containers
bidirectionalforward plus --std::list, std::set, std::map and their multi forms
random accessbidirectional plus it + n, it[n], it - it2, <std::deque
contiguous (C++17)random access, and elements are adjacent in memorystd::vector, std::array, std::string, built-in arrays

Each category includes the ones above it. The category is what decides the cost of an operation: it + 5 is one addition on a vector and does not compile on a list; std::sort needs random access, which is why std::list has its own sort() member; std::lower_bound on a std::set compiles but walks linearly (lesson 4). C++20 spells these categories as concepts — std::random_access_iterator and friends — and Module 14 uses them.

begin, end, const and reverse

begin() points at the first element and end() one past the last; the range is half-open, [begin, end), so an empty container has begin() == end() and dereferencing end() is undefined behaviour. The iterator type is Container::iterator — spelt auto in practice — and its const_iterator cousin refuses writes. cbegin()/cend() always give the const form, and calling begin() on a const container (or through a const& parameter) gives it too. rbegin()/rend() walk backwards: *rbegin() is the last element, and rit.base() converts back to a forward iterator that points one past *rit.

#include <algorithm>
#include <iostream>
#include <iterator>
#include <vector>

int main() {
    std::vector<int> v{10, 20, 30, 40};
    for (auto it = v.cbegin(); it != v.cend(); ++it) std::cout << *it << ' ';        // 10 20 30 40
    for (auto rit = v.rbegin(); rit != v.rend(); ++rit) std::cout << *rit << ' ';   // 40 30 20 10
    auto third = std::next(v.begin(), 2);                 // → 30, without changing v.begin()
    auto last = std::prev(v.end());                       // → 40
    std::cout << '\n' << *third << ' ' << *last << ' '
              << std::distance(v.begin(), last) << '\n';  // 30 40 3
    std::advance(third, -1);                              // third now → 20 (moved in place)
    std::cout << *third << ' ' << *std::max_element(v.begin(), v.end()) << '\n';  // 20 40
    return 0;
}

std::next(it, n) and std::prev(it, n) return a moved copy; std::advance(it, n) moves the iterator you pass; std::distance(a, b) counts the steps from a to b. On a random-access iterator all four are O(1) arithmetic; on a bidirectional or forward iterator they step, so std::distance(s.begin(), s.find(k)) on a std::set is O(n) and std::next(l.begin(), 1000) on a list is a thousand hops. Prefer them to hand-written it + 2 because they compile on every category and say what they mean.

The invalidation table

An operation invalidates an iterator when the element it pointed to has moved, been destroyed, or — for end() — is no longer where the end is. Using an invalidated iterator is undefined behaviour: it usually reads stale memory and sometimes appears to work, which is what makes the table worth learning rather than discovering.

Containerpush_back/inserterase
std::vector, std::stringif it reallocates: everything; otherwise every iterator at or after the insertion point (and end())every iterator at or after the erased element, and end()
std::dequeinsert at an end: all iterators, no references; insert in the middle: everythingerase at an end: only the erased element (and end() for the back); in the middle: everything
std::list, std::forward_listnothingonly the erased element
std::set, std::map (and multi)nothingonly the erased element
std::unordered_*if it rehashes: every iterator, no references; otherwise nothingonly the erased element

Two rules cover most of it. Node-based containers (list, set, map, unordered) keep an element's iterator valid until that element is erased — a rehash being the one exception for the unordered ones. Contiguous containers (vector, string, deque in the middle) invalidate everything from the point of change onwards, and a growing vector invalidates everything full stop. The practical habit: never hold an iterator or reference into a vector across a call that can change its size; hold an index, or reserve first.

Erasing while iterating

The classic bug:

for (auto it = v.begin(); it != v.end(); ++it) {
    if (*it % 2 == 0) v.erase(it);        // it now points past the removed slot — and ++it skips one;
}                                          // at the last element it steps past end(): undefined behaviour

erase on every container returns an iterator to the element after the one removed, and that return value is the whole idiom:

for (auto it = v.begin(); it != v.end(); ) {
    if (*it % 2 == 0) it = v.erase(it);   // erase advances for us
    else ++it;                            // only advance when we did not erase
}

The same loop is correct on a std::map (it->second), a std::list, a std::set and an unordered container, which is why it is the one to memorise. Two variants exist. m.erase(it++) — post-increment produces the old iterator for erase after stepping past it — works on node-based containers only, because on a vector the stepped iterator is itself invalidated by the erase; do not write it for a container you might later change. And since C++20, std::erase_if(c, pred) does the whole job for any standard container in one call (lesson 2); use it whenever the decision is a pure predicate on the element, and keep the explicit loop for when the body needs to do something else with each erased element, such as print it or move it somewhere.

The mirror image — inserting into a container while iterating it — has no safe idiom on a vector at all (each insert can reallocate). Collect the additions in a second container and append after the loop, or iterate by index up to a size captured before the loop.

Iterator ranges as the algorithm interface

Every algorithm in <algorithm> and <numeric> takes [first, last) rather than a container, and that decision is what lets one std::sort serve a vector, a deque, a string and a built-in array, and let it work on part of a container:

#include <algorithm>
#include <numeric>

std::vector<int> v{5, 3, 9, 1, 7};
std::sort(v.begin(), v.begin() + 3);                        // 3 5 9 1 7 — only the first three
auto nine = std::find(v.begin(), v.end(), 9);               // an iterator, or v.end() if absent
long long sum = std::accumulate(v.begin(), v.end(), 0LL);   // 25 — 0LL so the sum is a long long
v.erase(std::unique(v.begin(), v.end()), v.end());          // adjacent duplicates removed
std::ranges::sort(v);                                       // C++20: the container is the range

The range convention, the categories and the invalidation rules are the three things Module 14 assumes; the exercises there are written against iterator pairs and, in the last lesson, against C++20 ranges, which are the same idea with the pair packaged as one object.

Pitfalls

MistakeWhat happens
*v.end() or --v.begin()Undefined behaviour; the range is half-open
v.erase(it) inside a for (…; ++it)Skips the next element; runs off the end on the last one
it = v.erase(it) and ++it in the same iterationSkips one element after every erase
m.erase(it++) on a vectorThe incremented iterator was invalidated by the erase
auto it = v.begin(); v.push_back(x); *itDangling after a reallocation
it + 2 on a std::list or std::set iteratorDoes not compile; std::next(it, 2)
Comparing iterators from two different containersMeaningless; undefined behaviour
std::distance(s.begin(), it) on a large set in a loopO(n) per call; count as you go instead

Key takeaways

  • Iterators come in categories — forward (unordered), bidirectional (list, set, map), random access (deque), contiguous (vector, string, array) — and the category fixes what + n and the algorithms may do.
  • [begin, end) is half-open; cbegin/cend are read-only; rbegin/rend walk backwards; std::next/prev/distance/advance are O(1) only on random access.
  • Node-based containers keep an iterator valid until its element is erased (a rehash aside); a vector invalidates everything from the change point and everything on reallocation.
  • Erase while iterating with it = c.erase(it) and advance only in the else; use std::erase_if when the decision is a pure predicate.
  • Algorithms take [first, last) so one implementation serves every container and any sub-range.

Common questions

How do you erase elements while iterating in C++?

Use the iterator that erase returns and advance only when nothing was erased: for (auto it = c.begin(); it != c.end(); ) { if (cond(*it)) it = c.erase(it); else ++it; }. The same loop works on vectors, lists, maps, sets and unordered containers. When the decision is a plain predicate, C++20's std::erase_if(c, pred) does it in one call.

What are the iterator categories in C++?

Input and output, forward, bidirectional, random access and, since C++17, contiguous — each adds abilities to the one before. Forward iterators belong to std::forward_list and the unordered containers, bidirectional to std::list, std::set and std::map, random access to std::deque, and contiguous to std::vector, std::array and std::string.

What is the difference between std::next and std::advance?

std::next(it, n) returns a moved copy and leaves it unchanged, while std::advance(it, n) moves the iterator you pass in place and returns nothing. std::prev steps backwards and std::distance(a, b) counts the steps between two iterators. All are O(1) on random-access iterators and step one at a time on the others.

Why is v.erase(it) inside a for loop with ++it wrong?

After v.erase(it), it is invalidated and the next element has shifted into its slot, so ++it skips that element; at the last element it steps past end(), which is undefined behaviour. Assign the returned iterator — it = v.erase(it); — and increment only in the branch that did not erase.

Does erasing from a std::map invalidate other iterators?

No. In std::map, std::set and std::list, erasing an element invalidates only iterators to that element; every other iterator stays valid. That is why m.erase(it++) works on node-based containers, though it = m.erase(it) is clearer and works on every container.

Mark as readSign in to keep your place.

C++ Iterator Invalidation Rules and Erasing While Iterating

Which operations invalidate C++ iterators in vector, deque, list, map and unordered containers, the correct erase-while-iterating loop, and iterator categories.

  • Course: C++ study plan
  • Module: STL containers
  • Kind: Lesson
  • Reading time: 14 min
  • Runtime: Clang 18 · C++20

What is iterator invalidation in C++?

Iterator invalidation is when an operation on a container leaves an existing iterator, pointer or reference aimed at an element that has moved or been destroyed; using it is undefined behaviour. A std::vector that reallocates invalidates everything, and an insert or erase invalidates from that point onwards. Node-based containers — std::list, std::set, std::map — keep an iterator valid until its own element is erased.

Lesson

An iterator is a generalised pointer: something you can dereference to reach an element and increment to reach the next one. Every container hands them out through begin() and end(), every algorithm in <algorithm> takes a pair of them, and the range-based for of Module 3 is sugar over them. What makes them more than pointers is that each container promises a category — how far and in which directions its iterators can move — and an invalidation rule — which operations on the container leave existing iterators pointing at garbage. This lesson covers the categories and what they cost, the helper functions std::next, std::prev, std::distance and std::advance, const_iterator and reverse iterators, the invalidation table, the one correct way to erase while iterating, and iterator ranges as the interface algorithms share.

Categories

CategoryCan doContainers
input / outputread or write once, move forwardstreams (std::istream_iterator)
forwardread/write, ++, multi-passstd::forward_list, the unordered containers
bidirectionalforward plus --std::list, std::set, std::map and their multi forms
random accessbidirectional plus it + n, it[n], it - it2, <std::deque
contiguous (C++17)random access, and elements are adjacent in memorystd::vector, std::array, std::string, built-in arrays

Each category includes the ones above it. The category is what decides the cost of an operation: it + 5 is one addition on a vector and does not compile on a list; std::sort needs random access, which is why std::list has its own sort() member; std::lower_bound on a std::set compiles but walks linearly (lesson 4). C++20 spells these categories as concepts — std::random_access_iterator and friends — and Module 14 uses them.

begin, end, const and reverse

begin() points at the first element and end() one past the last; the range is half-open, [begin, end), so an empty container has begin() == end() and dereferencing end() is undefined behaviour. The iterator type is Container::iterator — spelt auto in practice — and its const_iterator cousin refuses writes. cbegin()/cend() always give the const form, and calling begin() on a const container (or through a const& parameter) gives it too. rbegin()/rend() walk backwards: *rbegin() is the last element, and rit.base() converts back to a forward iterator that points one past *rit.

#include <algorithm>
#include <iostream>
#include <iterator>
#include <vector>

int main() {
    std::vector<int> v{10, 20, 30, 40};
    for (auto it = v.cbegin(); it != v.cend(); ++it) std::cout << *it << ' ';        // 10 20 30 40
    for (auto rit = v.rbegin(); rit != v.rend(); ++rit) std::cout << *rit << ' ';   // 40 30 20 10
    auto third = std::next(v.begin(), 2);                 // → 30, without changing v.begin()
    auto last = std::prev(v.end());                       // → 40
    std::cout << '\n' << *third << ' ' << *last << ' '
              << std::distance(v.begin(), last) << '\n';  // 30 40 3
    std::advance(third, -1);                              // third now → 20 (moved in place)
    std::cout << *third << ' ' << *std::max_element(v.begin(), v.end()) << '\n';  // 20 40
    return 0;
}

std::next(it, n) and std::prev(it, n) return a moved copy; std::advance(it, n) moves the iterator you pass; std::distance(a, b) counts the steps from a to b. On a random-access iterator all four are O(1) arithmetic; on a bidirectional or forward iterator they step, so std::distance(s.begin(), s.find(k)) on a std::set is O(n) and std::next(l.begin(), 1000) on a list is a thousand hops. Prefer them to hand-written it + 2 because they compile on every category and say what they mean.

The invalidation table

An operation invalidates an iterator when the element it pointed to has moved, been destroyed, or — for end() — is no longer where the end is. Using an invalidated iterator is undefined behaviour: it usually reads stale memory and sometimes appears to work, which is what makes the table worth learning rather than discovering.

Containerpush_back/inserterase
std::vector, std::stringif it reallocates: everything; otherwise every iterator at or after the insertion point (and end())every iterator at or after the erased element, and end()
std::dequeinsert at an end: all iterators, no references; insert in the middle: everythingerase at an end: only the erased element (and end() for the back); in the middle: everything
std::list, std::forward_listnothingonly the erased element
std::set, std::map (and multi)nothingonly the erased element
std::unordered_*if it rehashes: every iterator, no references; otherwise nothingonly the erased element

Two rules cover most of it. Node-based containers (list, set, map, unordered) keep an element's iterator valid until that element is erased — a rehash being the one exception for the unordered ones. Contiguous containers (vector, string, deque in the middle) invalidate everything from the point of change onwards, and a growing vector invalidates everything full stop. The practical habit: never hold an iterator or reference into a vector across a call that can change its size; hold an index, or reserve first.

Erasing while iterating

The classic bug:

for (auto it = v.begin(); it != v.end(); ++it) {
    if (*it % 2 == 0) v.erase(it);        // it now points past the removed slot — and ++it skips one;
}                                          // at the last element it steps past end(): undefined behaviour

erase on every container returns an iterator to the element after the one removed, and that return value is the whole idiom:

for (auto it = v.begin(); it != v.end(); ) {
    if (*it % 2 == 0) it = v.erase(it);   // erase advances for us
    else ++it;                            // only advance when we did not erase
}

The same loop is correct on a std::map (it->second), a std::list, a std::set and an unordered container, which is why it is the one to memorise. Two variants exist. m.erase(it++) — post-increment produces the old iterator for erase after stepping past it — works on node-based containers only, because on a vector the stepped iterator is itself invalidated by the erase; do not write it for a container you might later change. And since C++20, std::erase_if(c, pred) does the whole job for any standard container in one call (lesson 2); use it whenever the decision is a pure predicate on the element, and keep the explicit loop for when the body needs to do something else with each erased element, such as print it or move it somewhere.

The mirror image — inserting into a container while iterating it — has no safe idiom on a vector at all (each insert can reallocate). Collect the additions in a second container and append after the loop, or iterate by index up to a size captured before the loop.

Iterator ranges as the algorithm interface

Every algorithm in <algorithm> and <numeric> takes [first, last) rather than a container, and that decision is what lets one std::sort serve a vector, a deque, a string and a built-in array, and let it work on part of a container:

#include <algorithm>
#include <numeric>

std::vector<int> v{5, 3, 9, 1, 7};
std::sort(v.begin(), v.begin() + 3);                        // 3 5 9 1 7 — only the first three
auto nine = std::find(v.begin(), v.end(), 9);               // an iterator, or v.end() if absent
long long sum = std::accumulate(v.begin(), v.end(), 0LL);   // 25 — 0LL so the sum is a long long
v.erase(std::unique(v.begin(), v.end()), v.end());          // adjacent duplicates removed
std::ranges::sort(v);                                       // C++20: the container is the range

The range convention, the categories and the invalidation rules are the three things Module 14 assumes; the exercises there are written against iterator pairs and, in the last lesson, against C++20 ranges, which are the same idea with the pair packaged as one object.

Pitfalls

MistakeWhat happens
*v.end() or --v.begin()Undefined behaviour; the range is half-open
v.erase(it) inside a for (…; ++it)Skips the next element; runs off the end on the last one
it = v.erase(it) and ++it in the same iterationSkips one element after every erase
m.erase(it++) on a vectorThe incremented iterator was invalidated by the erase
auto it = v.begin(); v.push_back(x); *itDangling after a reallocation
it + 2 on a std::list or std::set iteratorDoes not compile; std::next(it, 2)
Comparing iterators from two different containersMeaningless; undefined behaviour
std::distance(s.begin(), it) on a large set in a loopO(n) per call; count as you go instead

Key takeaways

  • Iterators come in categories — forward (unordered), bidirectional (list, set, map), random access (deque), contiguous (vector, string, array) — and the category fixes what + n and the algorithms may do.
  • [begin, end) is half-open; cbegin/cend are read-only; rbegin/rend walk backwards; std::next/prev/distance/advance are O(1) only on random access.
  • Node-based containers keep an iterator valid until its element is erased (a rehash aside); a vector invalidates everything from the change point and everything on reallocation.
  • Erase while iterating with it = c.erase(it) and advance only in the else; use std::erase_if when the decision is a pure predicate.
  • Algorithms take [first, last) so one implementation serves every container and any sub-range.

Common questions

How do you erase elements while iterating in C++?

Use the iterator that erase returns and advance only when nothing was erased: for (auto it = c.begin(); it != c.end(); ) { if (cond(*it)) it = c.erase(it); else ++it; }. The same loop works on vectors, lists, maps, sets and unordered containers. When the decision is a plain predicate, C++20's std::erase_if(c, pred) does it in one call.

What are the iterator categories in C++?

Input and output, forward, bidirectional, random access and, since C++17, contiguous — each adds abilities to the one before. Forward iterators belong to std::forward_list and the unordered containers, bidirectional to std::list, std::set and std::map, random access to std::deque, and contiguous to std::vector, std::array and std::string.

What is the difference between std::next and std::advance?

std::next(it, n) returns a moved copy and leaves it unchanged, while std::advance(it, n) moves the iterator you pass in place and returns nothing. std::prev steps backwards and std::distance(a, b) counts the steps between two iterators. All are O(1) on random-access iterators and step one at a time on the others.

Why is v.erase(it) inside a for loop with ++it wrong?

After v.erase(it), it is invalidated and the next element has shifted into its slot, so ++it skips that element; at the last element it steps past end(), which is undefined behaviour. Assign the returned iterator — it = v.erase(it); — and increment only in the branch that did not erase.

Does erasing from a std::map invalidate other iterators?

No. In std::map, std::set and std::list, erasing an element invalidates only iterators to that element; every other iterator stays valid. That is why m.erase(it++) works on node-based containers, though it = m.erase(it) is clearer and works on every container.

Exercises

Close the small accounts

Read n, then n lines of <name> <balance> into a std::map<std::string, int> (a repeated name keeps the last balance), then a threshold t. Walk the map once with an explicit iterator: for every account whose balance is below t, print closed <name> <balance> and erase it with it = accounts.erase(it); for every other account print kept <name> <balance> and advance. Finish with remaining=<size>.

The point of the exercise is the erase-while-iterating shape — advance only in the branch that did not erase. Do not use std::erase_if here.

Input: n, n lines, then t. Output: one line per account in key order, then the summary line.

4
bob 50
ada 120
cy 5
dee 75
60

prints

kept ada 120
closed bob 50
closed cy 5
kept dee 75
remaining=2

Playlist cursor on a std::list

Read n (at least 1), then n track names (one word each) into a std::list<std::string>, and keep a cursor — a list iterator — that starts on the first track. Then apply commands until the end of input:

  • next — move the cursor forward, wrapping from the last track to the first;
  • prev — move it backward, wrapping from the first to the last;
  • jump k — move forward k steps with wrapping (k ≥ 0; reduce it modulo the size first);
  • insert <name> — insert a track before the cursor; the cursor keeps pointing at the same track;
  • play — print playing <track> at <index>, the index being std::distance(playlist.begin(), cursor);
  • reverse — print reverse: followed by every track from last to first, via rbegin()/rend().

Use std::next, std::prev and std::distance rather than arithmetic — a list iterator has no +.

Input: n, the names, then commands. Output: one line per play or reverse.

3
alpha beta gamma
play
next
play
next
next
play
jump 4
play
reverse

prints

playing alpha at 0
playing beta at 1
playing alpha at 0
playing beta at 1
reverse: gamma beta alpha

In this module: STL containers

← The unordered containers — hashing, buckets and custom keys · Checkpoint — STL containers →