STL Iterators in C++ Explained — Types, Usage and Real-World Patterns
- 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.
Imagine a music playlist on your phone. You can tap the forward button to go to the next song, tap back to return to the previous one, or jump straight to song number 7. An STL iterator is exactly that — it's your 'current position' pointer inside a container like a vector or list. It lets your code navigate through data without knowing or caring how that data is stored underneath. Some iterators can only move forward (like a cassette tape), while others can jump anywhere instantly (like a digital track scrubber).
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 namespace io_thecodeforge { void demonstrateCategories() { // --- Random Access Iterator (std::vector) --- std::vector<int> temperatures = {28, 31, 19, 25, 22, 30, 17}; auto first = temperatures.begin(); auto last = temperatures.end(); // Constant time O(1) jump possible only for Random Access auto midpoint = first + (temperatures.size() / 2); std::cout << "Midpoint value: " << *midpoint << "\n"; // --- Bidirectional Iterator (std::list) --- std::list<std::string> tasks = {"render", "compress", "upload"}; auto it = tasks.end(); --it; // Moving backward is supported by Bidirectional std::cout << "Final task: " << *it << "\n"; // Generic approach: std::advance picks the best strategy auto start = tasks.begin(); std::advance(start, 2); // O(N) for list, but O(1) for vector std::cout << "Task at index 2: " << *start << "\n"; } } int main() { io_thecodeforge::demonstrateCategories(); return 0; }
Final task: upload
Task at index 2: upload
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> namespace io_thecodeforge { // SAFE: The Erase-Remove Idiom void vectorCleanup(std::vector<int>& vec) { // remove_if moves 'dead' elements to the end and returns new end auto new_end = std::remove_if(vec.begin(), vec.end(), [](int n) { return n % 2 != 0; // remove odd numbers }); // Now we actually resize the container once vec.erase(new_end, vec.end()); } // SAFE: List deletion using the return value of erase() void listCleanup(std::list<int>& lst) { for (auto it = lst.begin(); it != lst.end(); ) { if (*it % 2 != 0) { it = lst.erase(it); // Returns next valid iterator } else { ++it; } } } } int main() { std::vector<int> v = {1, 2, 3, 4, 5}; io_thecodeforge::vectorCleanup(v); std::list<int> l = {1, 2, 3, 4, 5}; io_thecodeforge::listCleanup(l); return 0; }
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.
#include <iostream> #include <vector> #include <string> #include <numeric> namespace io_thecodeforge { /** * Generic Function: Works with any Bidirectional Iterator range. * Notice we don't mention std::vector or std::list here. */ template <typename BidiIt> void printReversed(BidiIt first, BidiIt last) { std::reverse_iterator<BidiIt> r_first(last); std::reverse_iterator<BidiIt> r_last(first); while (r_first != r_last) { std::cout << *r_first << " "; ++r_first; } std::cout << "\n"; } void runDemo() { std::vector<std::string> names = {"Alice", "Bob", "Charlie"}; printReversed(names.begin(), names.end()); } } int main() { io_thecodeforge::runDemo(); return 0; }
begin() on a const-qualified container already returns a const_iterator — the compiler enforces it. cbegin() exists so you can explicitly get a const_iterator even from a non-const container. It's a signal to human readers: 'I chose read-only access here deliberately.' Prefer cbegin()/cend() in read-only functions to make intent unmistakable.| 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
Interview Questions on This Topic
- QExplain the internal differences between a Bidirectional Iterator and a Random Access Iterator. Why is std::sort limited to the latter?
- QWhat is Iterator Invalidation? Describe a scenario where adding an element to a container makes existing iterators dangerous to use.
- QHow does the 'Erase-Remove' idiom work, and why is it more efficient for std::vector than a manual loop calling
erase()repeatedly? - QWhat are Iterator Traits, and how does std::distance use them to achieve O(1) performance for certain containers while remaining O(N) for others?
- QIn a multi-threaded environment, why is it safer to pass const_iterator to read-only worker threads?
Frequently Asked Questions
What is the Big-O complexity of std::advance()?
The complexity depends on the iterator category. For Random Access Iterators (like std::vector), it is O(1) because it uses pointer arithmetic. For all other categories (like std::list), it is O(N) because it must increment the iterator step-by-step.
Why does std::vector invalidate iterators on push_back()?
When a vector reaches its capacity, it must allocate a new, larger memory block and move all elements to the new location. Any existing iterators still point to the old, now-freed memory, leading to a 'dangling' pointer scenario.
What is the difference between an iterator and a pointer in C++?
A raw pointer is a specific type of Random Access Iterator for contiguous memory. An iterator is a higher-level abstraction (often implemented as a class) that provides a pointer-like interface but works for non-contiguous structures like linked lists or hash maps.
How do you handle a loop where you need to erase multiple elements from a vector?
The most efficient way is the 'Erase-Remove' idiom: use std::remove_if to shift the elements you want to keep to the front of the vector, and then call vector::erase once on the remaining range at the end.
When should I use a reverse_iterator?
Use a reverse_iterator (via rbegin() and rend()) when you need to process a container from back-to-front, such as processing a stack-like log or displaying a list in descending order without manually managing decrementing pointers.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.