STL Iterators in C++ Explained — Types, Usage and Real-World Patterns
Every serious C++ program deals with collections of data — user records, sensor readings, queued tasks. The moment you need to visit, transform, or search those collections, you hit a fundamental question: how does my algorithm talk to my data structure in a way that works regardless of whether the data lives in a contiguous array, a doubly-linked list, or a balanced tree? That question is exactly what STL iterators were designed to answer, and it's why they sit at the very heart of the C++ Standard Library.
Before iterators existed, writing a generic sort or find meant either duplicating code for every container type or sacrificing type safety with raw void pointers. Iterators solve this by acting as a common contract — a standardised interface that any container can fulfil and any algorithm can depend on. The algorithm doesn't need to know it's talking to a vector; it just needs an iterator that supports the operations it requires. This separation is what makes std::sort work on a vector but std::list::sort exist as a member function — the details matter, and understanding them saves you from subtle bugs and wasted CPU cycles.
By the end of this article you'll know the five iterator categories and why the distinction between them is not just academic trivia, you'll be able to choose the right iterator for the job, write range-based patterns that professional C++ engineers use daily, avoid the two most common iterator invalidation crashes, and walk into a technical interview able to answer iterator questions with genuine depth rather than surface-level recitation.
The Five Iterator Categories — What They Can Do and Why It Matters
The C++ standard defines iterators not by their type name but by the operations they support. Think of them as tiers of capability, each a superset of the one below it.
Input Iterator — read-only, single-pass. You can dereference it once and advance forward. Classic example: reading from std::cin via std::istream_iterator. Once you've moved past a value, it's gone.
Output Iterator — write-only, single-pass. You can assign through it and advance. std::ostream_iterator writes to a stream this way.
Forward Iterator — read and write, multi-pass. You can make a copy of the iterator, advance one copy, and the original still points to the same element. std::forward_list uses these.
Bidirectional Iterator — everything a forward iterator can do, plus operator-- to step backwards. std::list and std::map give you these.
Random Access Iterator — the full package. Jump forward or backward by any amount with +, -, [], and compare positions with < and >. std::vector and std::deque provide these, and they're what std::sort requires.
Why does the categorisation matter? Because algorithms advertise which category they require. If you try to pass a std::list iterator to std::sort, you get a compile-time error — not a silent wrong answer at runtime. The type system is doing safety work for you.
#include <iostream> #include <vector> #include <list> #include <iterator> // std::distance, std::advance #include <algorithm> // std::sort, std::find int main() { // --- Random Access Iterator (std::vector) --- std::vector<int> temperatures = {28, 31, 19, 25, 22, 30, 17}; // Iterators returned by begin() / end() are random-access for vector auto first = temperatures.begin(); auto last = temperatures.end(); // Random-access: we can jump forward by 3 in O(1) auto midpoint = first + 3; std::cout << "Element at index 3: " << *midpoint << "\n"; // 25 // Random-access: std::sort requires this category std::sort(first, last); std::cout << "Sorted temperatures: "; for (auto it = temperatures.begin(); it != temperatures.end(); ++it) { std::cout << *it << " "; // dereference to read the value } std::cout << "\n"; // --- Bidirectional Iterator (std::list) --- std::list<std::string> taskQueue = {"render", "compress", "upload", "notify"}; auto taskIt = taskQueue.end(); --taskIt; // step BACKWARDS — valid for bidirectional, not for forward std::cout << "Last task: " << *taskIt << "\n"; // notify // std::sort(taskQueue.begin(), taskQueue.end()); // COMPILE ERROR — list // iterators are not random-access. Use taskQueue.sort() instead. taskQueue.sort(); std::cout << "Sorted tasks: "; for (const auto& task : taskQueue) { std::cout << task << " "; } std::cout << "\n"; // --- std::advance and std::distance work across categories --- std::vector<int> scores = {10, 20, 30, 40, 50}; auto scoreIt = scores.begin(); std::advance(scoreIt, 3); // jumps by 3 — O(1) for vector, O(n) for list std::cout << "Score after advance(3): " << *scoreIt << "\n"; // 40 auto gap = std::distance(scores.begin(), scoreIt); // O(1) for vector std::cout << "Distance from begin: " << gap << "\n"; // 3 return 0; }
Sorted temperatures: 17 19 22 25 28 30 31
Last task: notify
Sorted tasks: compress notify render upload
Score after advance(3): 40
Distance from begin: 3
Iterator Invalidation — The Silent Crash You Must Understand
Here's the scenario that causes production bugs: you grab an iterator to an element, do some work, then modify the container — and suddenly your iterator is pointing at garbage memory or a completely wrong element. This is called iterator invalidation, and the rules differ between containers.
For std::vector, any operation that causes a reallocation (push_back when size == capacity, insert, erase, resize) invalidates ALL iterators, pointers, and references to elements. Even erasing a single element invalidates iterators to every element after it because they all shift left.
For std::list, insert never invalidates any existing iterator — nodes just get linked in. Erase only invalidates the iterator to the element being erased; everything else stays valid. This is why std::list exists: predictable iterator stability.
For std::map and std::unordered_map, insert doesn't invalidate any iterators. Erase only invalidates the iterator to the erased element.
The practical takeaway is this: if you need to erase elements while iterating over a vector, you need a different pattern (erase-remove idiom or index-based loop going backwards). For lists and maps you can erase inside the loop safely — but you must capture the next iterator before you erase.
#include <iostream> #include <vector> #include <list> #include <algorithm> // std::remove_if // Remove all scores below a threshold from a VECTOR safely void removeWeakScores(std::vector<int>& scores, int threshold) { // WRONG approach (commented out) — erasing inside a ranged for loop // over a vector causes undefined behaviour because iterators are // invalidated after erase and the loop's internal iterator is now dangling. // // for (auto it = scores.begin(); it != scores.end(); ++it) { // if (*it < threshold) scores.erase(it); // BUG: it is now invalid! // } // CORRECT approach — erase-remove idiom // std::remove_if doesn't actually erase; it shuffles matching elements // to the end and returns an iterator to the new logical end. auto newEnd = std::remove_if( scores.begin(), scores.end(), [threshold](int score) { return score < threshold; } ); // Now erase the dead zone in a single call — only ONE reallocation risk scores.erase(newEnd, scores.end()); } // Remove all tasks containing "legacy" from a LIST safely void removeLegacyTasks(std::list<std::string>& tasks) { // For std::list, erasing a node only invalidates the iterator TO that node. // We capture the next iterator BEFORE we erase, so the loop stays valid. for (auto it = tasks.begin(); it != tasks.end(); /* no increment here */) { if (it->find("legacy") != std::string::npos) { it = tasks.erase(it); // erase returns the next valid iterator } else { ++it; // only advance if we didn't erase } } } int main() { std::vector<int> playerScores = {45, 12, 88, 7, 63, 3, 95, 21}; std::cout << "Before cleanup: "; for (int s : playerScores) std::cout << s << " "; std::cout << "\n"; removeWeakScores(playerScores, 20); std::cout << "After removing scores below 20: "; for (int s : playerScores) std::cout << s << " "; std::cout << "\n"; std::list<std::string> pipeline = { "fetch_data", "legacy_transform", "validate", "legacy_format", "publish" }; removeLegacyTasks(pipeline); std::cout << "Pipeline after removing legacy steps: "; for (const auto& step : pipeline) std::cout << step << " "; std::cout << "\n"; return 0; }
After removing scores below 20: 45 88 63 95
Pipeline after removing legacy steps: fetch_data validate publish
Reverse Iterators, const_iterators, and Writing Generic Code
Once you're comfortable with forward iteration, two refinements immediately pay dividends: iterating in reverse and enforcing read-only access.
Reverse iterators wrap a regular iterator and flip the direction of ++ and --. Every standard container that supports bidirectional or random-access iteration provides rbegin() and rend(). The mental model: rbegin() points at the last element, rend() points one-before-the-first.
const_iterators (returned by cbegin() and cend()) give you a read-only view of the container's elements. Dereferencing one gives a const reference — the compiler won't let you accidentally modify data you promised to leave alone. Use them in any function that only reads, and you communicate intent clearly to the next developer.
Writing generic code with iterators means templating on the iterator type itself rather than on the container. This is how the STL algorithms are written. Your own functions can follow the same pattern — accept an iterator range [first, last) and work on anything that satisfies the required iterator category. C++20 ranges build on this idea, but understanding classic iterator-based generics is the foundation you need first.
#include <iostream> #include <vector> #include <map> #include <string> #include <numeric> // std::accumulate #include <algorithm> // std::find_if // Generic function — works with ANY container that provides // bidirectional or random-access iterators. // InputIt is deduced by the compiler at each call site. template <typename InputIt> void printReversed(InputIt first, InputIt last) { // Reverse iterators constructed from the end and begin of the range // rFirst points to the element BEFORE last (i.e., the last valid element) std::reverse_iterator<InputIt> rFirst(last); std::reverse_iterator<InputIt> rLast(first); for (auto it = rFirst; it != rLast; ++it) { std::cout << *it << " "; } std::cout << "\n"; } // Demonstrates const_iterator — this function cannot modify the container void analyseReadings(const std::vector<double>& sensorReadings) { // cbegin() / cend() return const_iterator even on a non-const container. // Using them on a const& makes it explicit: we will NOT modify data. double total = std::accumulate(sensorReadings.cbegin(), sensorReadings.cend(), 0.0); double average = total / static_cast<double>(sensorReadings.size()); // Find the first reading that's an anomaly (more than 2x the average) auto anomaly = std::find_if( sensorReadings.cbegin(), sensorReadings.cend(), [average](double reading) { return reading > average * 2.0; } ); if (anomaly != sensorReadings.cend()) { // std::distance between cbegin() and anomaly gives the index auto index = std::distance(sensorReadings.cbegin(), anomaly); std::cout << "Anomaly found at index " << index << ": value = " << *anomaly << "\n"; } else { std::cout << "No anomalies detected.\n"; } std::cout << "Average reading: " << average << "\n"; } int main() { std::vector<int> floorNumbers = {1, 2, 3, 4, 5, 6, 7, 8}; std::cout << "Floors top-down: "; printReversed(floorNumbers.begin(), floorNumbers.end()); // uses vector iterators std::vector<std::string> cities = {"Tokyo", "Berlin", "Lagos", "Sydney"}; std::cout << "Cities reversed: "; printReversed(cities.begin(), cities.end()); // same function, different type // Reverse iteration over a map (ordered by key, so reversed = descending key) std::map<int, std::string> errorCodes = { {200, "OK"}, {404, "Not Found"}, {500, "Server Error"}, {403, "Forbidden"} }; std::cout << "Error codes (descending): "; for (auto it = errorCodes.rbegin(); it != errorCodes.rend(); ++it) { std::cout << it->first << ":" << it->second << " "; } std::cout << "\n"; // Sensor analysis using const_iterator internally std::vector<double> readings = {18.2, 19.1, 17.8, 20.0, 61.5, 18.9, 19.4}; analyseReadings(readings); return 0; }
Cities reversed: Sydney Lagos Berlin Tokyo
Error codes (descending): 500:Server Error 404:Not Found 403:Forbidden 200:OK
Anomaly found at index 4: value = 61.5
Average reading: 24.9857
| Iterator Category | Move Forward | Move Backward | Random Jump (it+n) | Multi-pass | Typical Container |
|---|---|---|---|---|---|
| Input Iterator | Yes (++) | No | No | No — single pass only | std::istream_iterator |
| Output Iterator | Yes (++) | No | No | No — single pass only | std::ostream_iterator |
| Forward Iterator | Yes (++) | No | No | Yes | std::forward_list |
| Bidirectional Iterator | Yes (++) | Yes (--) | No | Yes | std::list, std::map, std::set |
| Random Access Iterator | Yes (++) | Yes (--) | Yes — O(1) | Yes | std::vector, std::deque, raw arrays |
🎯 Key Takeaways
- The five iterator categories (input, output, forward, bidirectional, random-access) form a capability hierarchy — algorithms advertise which category they require, and the compiler enforces it at compile time, not runtime.
- Iterator invalidation rules are container-specific: vector invalidates all iterators on reallocation or any erase; list only invalidates the iterator to the erased node; map only invalidates the erased element's iterator.
- Use cbegin()/cend() in any function that only reads data — it makes your intent explicit and prevents accidental writes, even when the container itself is not const-qualified.
- Prefer std::advance and std::distance over raw arithmetic in template code — they automatically select O(1) or O(n) behaviour based on the iterator category, keeping your generic functions both correct and efficient.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Using an invalidated vector iterator after push_back — symptom is a segfault or garbage values that appear only sometimes (because reallocation isn't guaranteed every call). Fix: never hold a vector iterator across a push_back. Either use indices (scores[i]) or call reserve() upfront to prevent reallocation entirely.
- ✕Mistake 2: Erasing elements inside a range-based for loop on a vector — the compiler won't catch this; you'll get undefined behaviour at runtime where elements are skipped or the program crashes. Fix: use the erase-remove idiom (std::remove_if + erase) or collect indices/values to erase in a separate pass, then erase after the loop.
- ✕Mistake 3: Comparing iterators from two different containers — writing if (vecA.begin() == vecB.begin()) compiles fine but is undefined behaviour. Iterators are only comparable within the same range. Fix: make sure both iterators used in a comparison or a distance call originate from the same container instance.
Interview Questions on This Topic
- QWhat is the difference between a const_iterator and a regular iterator, and when would you explicitly choose cbegin() over begin() even on a non-const container?
- QWhy can't you use std::sort on a std::list, and what is the correct alternative? What property of list iterators makes sort impossible to apply directly?
- QExplain iterator invalidation rules for std::vector versus std::map. If you're building a system where you cache iterators to frequently-accessed map entries and occasionally erase other entries, is that safe — and why?
Frequently Asked Questions
What is the difference between an iterator and a pointer in C++?
A raw pointer is actually a valid random-access iterator — it satisfies the full random-access iterator interface. The difference is that an iterator is a generalisation: it provides the same syntactic interface (*, ++, --, +) but the underlying implementation can be a node pointer in a linked list, a handle into a tree, or a stream position. Pointers only work for contiguous memory; iterators work for any data structure that implements the interface.
What does it mean for an iterator range to be half-open [begin, end)?
Every STL algorithm works on the convention that begin points to the first valid element and end points one past the last element — it is never dereferenced. This makes empty ranges trivial to represent (begin == end), makes loop termination conditions simple (it != end), and eliminates off-by-one errors. It also means you can pass begin and end of an empty container to any algorithm without crashing.
When should I use iterator-based loops versus range-based for loops?
Use range-based for (for (auto& item : container)) whenever you're simply visiting every element in order and not modifying the iterator position mid-loop. Switch to explicit iterators when you need to erase during iteration, skip elements, compare positions, call algorithms that require an iterator range, or iterate in reverse with rbegin()/rend(). Range-based for is cleaner for simple traversal; explicit iterators give you control when you need it.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.