C++ Iterators and Ranges Tutorial
A practical walk through C++ iterator categories, the ranges library, views, and how to compose pipelines that are both safe and zero-overhead.
What you'll learn
- ✓The five iterator categories and what they guarantee
- ✓How begin/end pairs power every STL algorithm
- ✓The ranges library and views in C++20
- ✓How lazy view composition avoids allocations
- ✓Migration tips from iterator pairs to ranges
Prerequisites
- •Basic familiarity with std::vector and std::sort
What and Why
Iterators are how C++ generalizes “a position in a sequence.” Every standard algorithm takes a pair of iterators, and every container exposes some. C++20’s ranges library adds a higher-level layer on top: you can pass a whole container, compose lazy views, and chain transformations without writing for-loops.
This combination gives you Python-like expressiveness with C++ performance: pipelines compile down to the same loops you would have written by hand.
Mental Model
An iterator behaves like a generalized pointer. Different iterator categories support different operations.
input : read once, ++; e.g. std::istream_iterator
forward : read many times, ++; e.g. std::forward_list
bidirectional : ++, --; e.g. std::list, std::map
random-access : +n, -n, [], <; e.g. std::vector, raw pointers
contiguous : random-access + memory layout; e.g. std::array, std::vector A range is anything you can call begin() and end() on. Views are cheap, lazy ranges that wrap other ranges.
Hands-on Example
Classic iterator usage and the ranges equivalent.
#include <algorithm>
#include <iostream>
#include <ranges>
#include <vector>
int main() {
std::vector<int> v{5, 2, 8, 1, 9, 3, 7};
// Iterator-based: sort, then print evens doubled
std::sort(v.begin(), v.end());
for (auto it = v.begin(); it != v.end(); ++it)
if (*it % 2 == 0) std::cout << (*it * 2) << ' ';
std::cout << '\n';
// Ranges-based: same idea
std::ranges::sort(v);
auto pipeline = v
| std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * 2; });
for (int x : pipeline) std::cout << x << ' ';
std::cout << '\n';
// Lazy: nothing is computed until we iterate
auto first_three = pipeline | std::views::take(3);
std::cout << std::ranges::distance(first_three) << '\n';
}
Notice that views never copy the underlying data. They store iterators and apply transformations on demand. When you stop iterating, work stops.
Common Pitfalls
- Dangling iterators: storing
vec.end()and then growing the vector invalidates it. Each container documents which operations invalidate which iterators; read those rules. - Treating filter/transform iterators as random-access: they are not. Algorithms that need
<or+nwill not compile, which is good news but sometimes surprising. - Returning views from functions: a view that captures a temporary container can dangle. Return owning containers across boundaries; use views internally.
- Off-by-one with
end():end()is one past the last element, not the last element. Dereferencing it is undefined. - Mixing signed and unsigned arithmetic: container
size()returns an unsigned type. Comparing it to a signed loop variable triggers warnings and bugs. Usestd::ssizeor range-for.
Practical Tips
Prefer range-for and ranges algorithms over manual iterator loops. They are shorter and harder to get wrong:
for (auto& x : container) ...; // mutate in place
std::ranges::for_each(c, fn); // explicit functional style
For partial work, std::views::take(n) and std::views::drop(n) give you slicing without copying.
When you really need a concrete container from a view, materialize it explicitly with std::ranges::to<std::vector> (C++23) or by constructing from iterators in C++20:
std::vector<int> out(pipeline.begin(), pipeline.end());
Custom containers benefit from exposing iterators: define begin(), end(), an iterator type, and the right category tag. Once those exist, every standard algorithm works on your type.
For debugging, remember that most iterator bugs are use-after-invalidation. When in doubt, recompute the iterator after any structural change.
Wrap-up
Iterators are the foundation; ranges are the ergonomic layer on top. Together they let you express “transform this sequence” in a way that is both declarative and zero-cost. Start by replacing simple loops with std::ranges algorithms, then compose views as your comfort grows. The result is code that reads like an outline and runs like a hand-written loop.
Related articles
- C++ C++ STL Containers Cheatsheet
A practical comparison of the C++ standard containers: vector, list, deque, map, unordered_map, set, and friends, with guidance on which to pick when.
- C++ C++ STL Containers: vector, map, set, unordered_map
Pick the right C++ STL container for the job: vector, deque, list, map, set, unordered_map, and unordered_set with their complexity guarantees.
- C++ C++ CMake Tutorial: Build Your First Project
Learn modern CMake for C++ — targets, properties, and dependencies — and configure a small project that compiles cleanly across platforms.
- C++ C++ constexpr and Compile-Time Computing
How constexpr, consteval, and constinit let you move computation from runtime to compile time, with practical patterns and the rules that govern them.