Range-Based for Loop
C++ range-based for — desugaring rules, sentinel support, custom iterators, C++17/20/23 extensions, and iterator-invalidation pitfalls.
Range-Based for Loopsince C++11A language-level loop that iterates over every element of a range by desugaring into iterator operations driven by begin() and end() — or since C++17, a begin-iterator paired with a heterogeneous sentinel of a different type.
Overview
The range-based for is not merely a library convenience — it is defined by the language standard with precise desugaring rules that govern ADL lookup, temporary lifetime extension, and (since C++17) type-heterogeneous termination. Understanding the desugaring is essential for writing correct custom ranges and avoiding subtle UB.
Key evolution by standard:
- C++11 — introduced;
beginandendmust return the same type. - C++17 —
beginandendmay have different types (sentinel support); structured bindings integrate cleanly inside the loop variable declaration. - C++20 — init-statement syntax added;
std::default_sentinel_tstandardised;std::views::reverseand other range adaptors available. - C++23 —
std::views::enumerateprovides indexed iteration without a manual counter.
Syntax
for (const auto& x : range) { } // read-only reference — preferred for non-trivial types
for (auto& x : range) { } // mutable reference
for (auto x : range) { } // by value — copies each element
for (auto&& x : range) { } // universal ref — required for proxy-reference containers
// C++20: init-statement scopes a variable to the loop
for (auto data = get_data(); auto& x : data) { // C++20
process(x);
}The init-statement (C++20) replaces the common pre-loop declaration idiom and keeps the variable out of the enclosing scope. When get_data() returns by value, declare with auto, not auto& — an lvalue reference to a temporary is ill-formed for non-const.
Desugaring
The compiler transforms every range-based for as follows (C++17 and later):
// Source:
for (decl : range_expr) body;
// Expansion:
{
auto&& __range = range_expr; // binds lvalue or rvalue; extends temporary lifetime
auto __begin = begin(__range); // member begin() or ADL; types may differ from C++17
auto __end = end(__range); // member end() or ADL
for (; __begin != __end; ++__begin) {
decl = *__begin;
body;
}
}Lifetime extension: auto&& binds the range expression and extends any temporary's lifetime to the full loop. for (const auto& x : make_vector()) {} is safe — the vector lives until the closing brace of the expansion.
begin/end lookup: member functions are tried first (__range.begin(), __range.end()); if absent, unqualified ADL lookup proceeds with std::begin/std::end injected as fallback. Put free-function overloads in the same namespace as the type so ADL finds them.
C++11/14 restriction: decltype(__begin) and decltype(__end) had to match. Sentinels with distinct types required a wrapper.
C++17 relaxation: the termination condition __begin != __end is valid when the two have different types — enabling efficient sentinel-terminated ranges without materialising a past-the-end iterator.
Examples
Map iteration with structured bindings
std::map<std::string, int> scores{{"Alice", 95}, {"Bob", 87}};
for (const auto& [name, score] : scores) { // C++17 structured bindings
std::println("{}: {}", name, score); // C++23 std::println
}Indexed iteration
std::vector<std::string> tokens{"foo", "bar", "baz"};
// C++23: views::enumerate
for (auto [i, tok] : std::views::enumerate(tokens)) { // C++23
std::println("{}: {}", i, tok);
}
// Pre-C++23 workaround
std::size_t i = 0;
for (const auto& tok : tokens) std::println("{}: {}", i++, tok);Custom range type
The minimum interface: operator* (dereference), prefix operator++ (advance), operator== (C++17 sentinel model) or operator!= (C++11/14 same-type model).
class IntRange {
int first_, last_;
public:
IntRange(int first, int last) : first_{first}, last_{last} {}
struct Iterator {
int cur;
int operator*() const { return cur; }
Iterator& operator++() { ++cur; return *this; }
bool operator==(const Iterator& o) const { return cur == o.cur; }
};
Iterator begin() const { return {first_}; }
Iterator end() const { return {last_}; }
};
for (int x : IntRange{1, 6}) std::print("{} ", x); // 1 2 3 4 5Sentinel pattern — null-terminated string
Sentinels avoid computing a past-the-end pointer, which is impossible for null-terminated strings and stream-like sources:
struct NullTermView {
const char* ptr;
struct Iter {
const char* p;
char operator*() const { return *p; }
Iter& operator++() { ++p; return *this; }
bool operator==(std::default_sentinel_t) const { return *p == '\0'; } // C++20
};
Iter begin() const { return {ptr}; }
std::default_sentinel_t end() const { return {}; } // C++20
};
for (char c : NullTermView{"hello"}) std::print("{} ", c); // h e l l ostd::default_sentinel_t (C++20) is the standard empty sentinel tag — use it instead of rolling a custom struct Sentinel{}.
ADL-based extension for third-party types
Place free-function overloads in the type's own namespace so ADL finds them without modifying the class:
namespace mylib {
struct Grid { int cols, rows; };
struct GridIter {
const Grid* g; int col, row;
std::pair<int,int> operator*() const { return {col, row}; }
GridIter& operator++() {
if (++col == g->cols) { col = 0; ++row; }
return *this;
}
bool operator==(const GridIter& o) const {
return col == o.col && row == o.row;
}
};
GridIter begin(const Grid& g) { return {&g, 0, 0}; }
GridIter end (const Grid& g) { return {&g, 0, g.rows}; }
}
mylib::Grid grid{4, 3};
for (auto [c, r] : grid) std::print("({},{}) ", c, r);Best Practices
Use const auto& by default for any non-trivial type. Raw auto silently copies — catastrophic for std::string, std::vector, and heap-owning types. Use auto& only when mutation is intended.
Use auto&& for generic code or when iterating proxy-reference containers. auto&& binds correctly to both real lvalue references and proxy objects, whereas auto& fails on rvalue proxies.
Prefer std::views::reverse over a raw index loop when backward traversal is needed:
for (auto& x : std::views::reverse(v)) { } // C++20
// not: for (int i = (int)v.size()-1; i >= 0; --i) {}Scope your loop-setup variables with the C++20 init-statement rather than polluting the enclosing scope with loop-only state.
Move elements out explicitly:
std::vector<std::string> dst;
for (auto& s : src) dst.push_back(std::move(s)); // moves string buffers, no copyCommon Pitfalls
Iterator invalidation during the loop
std::vector<int> v{1, 2, 3};
for (const auto& x : v) {
if (x == 2) v.push_back(99); // UB: reallocation invalidates __begin and __end
}The desugared __begin and __end are captured once before the body and never updated. Any container mutation that invalidates iterators produces undefined behaviour.
auto copies when a reference is expected
std::map<int, std::vector<int>> m = build_map();
for (auto kv : m) { // copies key AND entire vector on every iteration
use(kv.second);
}
for (const auto& [k, v] : m) { // zero copies
use(v);
}Structured binding mutability is easy to get wrong
std::vector<std::pair<int,int>> pairs{{1,2},{3,4}};
for (auto [a, b] : pairs) { a = 0; } // copies the pair; modifies the copy, not pairs[i]
for (auto& [a, b] : pairs) { a = 0; } // a is a reference; modifies pairs[i].firststd::vector<bool> proxy references
std::vector<bool> stores packed bits. Its iterator dereferences to a proxy object, not a bool&. auto captures the proxy; bool forces conversion:
std::vector<bool> flags{true, false, true};
for (auto f : flags) { } // f is a proxy object, not bool
for (bool f : flags) { } // correct: proxy converts to bool
for (auto&& f : flags) { } // binds proxy as forwarding reference — reads correctlyChained member calls on a temporary range
auto&& extends only the top-level temporary produced by the range expression:
// SAFE: top-level temporary is extended
for (const auto& x : make_vector()) { use(x); }
// POTENTIALLY DANGEROUS: if get_view() returns a string_view into a local temporary
// inside some_obj, and some_obj is itself a temporary, the view may dangle
for (char c : some_temporary_obj.get_view()) { } // audit the chain carefullyRange-Based for with Initializer (C++20)
The init-statement prevents a class of dangling-reference bugs involving temporaries. When the range expression calls a function that returns a value type, the desugaring's auto&& extends the top-level temporary — but only one level deep. If you bind a reference-like view into that temporary and the temporary evaporates, the view dangles.
// DANGEROUS before C++23: temporary vector destroyed before loop body runs
// (guaranteed UB — do not rely on compiler extensions)
for (auto& x : get_vec()) { // get_vec() returns std::vector<int> by value
use(x); // UB in C++11/17 implementations that destroy before first iteration
}The C++20 init-statement names the temporary, giving it a lifetime that covers the entire loop:
// C++20
for (auto container = get_vec(); auto& x : container) { // C++20
use(x); // safe: container lives for the full loop
}
// Also useful for caching an expensive call:
for (auto& db = get_database_handle(); auto& row : db.query("SELECT ...")) { // C++20
process(row);
}Use auto, not auto&, in the init clause when the function returns by value. An lvalue reference to a temporary (auto& v = get_vec()) is ill-formed for non-const types; const auto& would work but prevents mutation. auto takes ownership cleanly.
Structured Bindings in Range-Based for
Structured bindings decompose each element into named parts, eliminating .first/.second noise. The binding variable qualifiers (auto&, const auto&, auto) apply to the whole element; the individual names a, b then refer into that element according to the usual structured-binding rules.
// C++17 — map iteration
std::map<std::string, int> scores{{"Alice", 95}, {"Bob", 87}};
for (const auto& [name, score] : scores) { // C++17
std::println("{}: {}", name, score); // name and score are const refs into the pair
}
// C++17 — vector of pairs
std::vector<std::pair<std::string, double>> results{{"latency", 1.2}, {"throughput", 980.5}};
for (auto& [name, score] : results) { // C++17
score *= 1.1; // modifies results[i].second in-place
}Use const auto& when iterating for read-only access to non-trivial element types; use auto& when you need to mutate the individual fields; use auto only for trivially-copyable elements where copying is cheap. For indexed iteration, C++23 provides std::views::enumerate:
std::vector<std::string> names{"Alice", "Bob", "Carol"};
for (auto [idx, val] : std::views::enumerate(names)) { // C++23
std::println("{}: {}", idx, val); // idx is std::size_t, val is std::string&
}Custom Range Support: begin/end Protocol
Any type becomes range-for-able by exposing begin() and end() that return iterators satisfying three operations: operator*, prefix operator++, and operator!=. The standard calls this the input iterator minimum; ranges need nothing more for a basic range-for loop.
There are two ways to attach the protocol to a type:
// C++11 — Option 1: member begin()/end()
class IntRange {
int first_, last_;
public:
IntRange(int first, int last) : first_{first}, last_{last} {}
struct Iterator {
int cur;
int operator*() const { return cur; } // dereference
Iterator& operator++() { ++cur; return *this; } // advance
bool operator!=(const Iterator& o) const { return cur != o.cur; } // termination
};
Iterator begin() const { return {first_}; }
Iterator end() const { return {last_}; }
};
for (int x : IntRange{1, 6}) std::print("{} ", x); // C++11 → 1 2 3 4 5For types you cannot modify (third-party or legacy), provide free functions via ADL:
// C++11 — Option 2: free begin()/end() in the same namespace (ADL)
namespace legacy {
struct Buffer { const char* data; std::size_t size; };
struct BufIter {
const char* ptr;
char operator*() const { return *ptr; }
BufIter& operator++() { ++ptr; return *this; }
bool operator!=(const BufIter& o) const { return ptr != o.ptr; } // C++11
};
BufIter begin(const Buffer& b) { return {b.data}; } // found by ADL
BufIter end (const Buffer& b) { return {b.data + b.size}; }
}
legacy::Buffer buf{"hello", 5};
for (char c : buf) std::print("{}", c); // C++11The iterator need not inherit from anything. operator*, prefix operator++, and operator!= (or operator== plus std::default_sentinel_t in C++20) are the complete required surface.
std::ranges::views in Range-Based for
Views are lazy, zero-copy range adaptors — they compose pipelines that materialise one element at a time inside the loop body. No intermediate container is ever allocated.
std::vector<int> vec{1, 2, 3, 4, 5, 6, 7, 8};
// C++20 — filter: iterate only even elements
for (auto x : std::views::filter(vec, [](int n){ return n % 2 == 0; })) { // C++20
std::print("{} ", x); // 2 4 6 8
}
// C++20 — transform: iterate squared values without a new vector
for (auto x : std::views::transform(vec, [](int n){ return n * n; })) { // C++20
std::print("{} ", x); // 1 4 9 16 25 36 49 64
}
// C++20 — pipeline syntax with operator|
for (auto x : vec | std::views::filter([](int n){ return n % 2 == 0; })
| std::views::transform([](int n){ return n * n; })) { // C++20
std::print("{} ", x); // 4 16 36 64
}std::views::iota(0, n) generates integers [0, n) without an underlying container — a clean replacement for for (int i = 0; i < n; ++i):
for (auto i : std::views::iota(0, 10)) { // C++20
std::print("{} ", i); // 0 1 2 … 9
}For parallel iteration over two ranges by index, C++23 adds std::views::zip:
std::vector<std::string> keys{"a", "b", "c"};
std::vector<int> vals{1, 2, 3 };
for (auto [k, v] : std::views::zip(keys, vals)) { // C++23
std::println("{} -> {}", k, v);
}Performance: auto vs auto& vs const auto&
The choice of loop-variable qualifier determines whether each element is copied, referenced, or const-referenced — and for non-trivial types the difference is a heap allocation per iteration. For a std::vector<std::string> of 10 000 strings, auto invokes the copy constructor 10 000 times; const auto& invokes nothing.
std::vector<std::string> words = load_dictionary(); // 10 000 strings
// C++11 — BAD: copies each string on every iteration
for (auto w : words) { // C++11 — heap allocation × 10 000
use(w);
}
// C++11 — GOOD: zero copies, read-only
for (const auto& w : words) { // C++11 — no allocation
use(w);
}
// C++11 — GOOD: zero copies, mutation allowed
for (auto& w : words) { // C++11 — modifies words[i] in place
to_upper(w);
}For trivially-copyable types — int, double, raw pointers, small structs with no destructor — the compiler generates identical code for auto and const auto&; a reference to an int is typically the same size as the int itself. Apply the rule selectively:
std::vector<int> counts{1, 2, 3, 4};
for (auto c : counts) { use(c); } // C++11 — fine: int is trivially copyable
for (auto& c : counts) { c *= 2; } // C++11 — mutation
for (const auto& c : counts) { use(c); } // C++11 — also fine, compiler elides the refRule of thumb: always use const auto& for read-only loops over non-trivial types (std::string, std::vector, user-defined classes with heap members). For primitive types or when you need mutation, use auto or auto& respectively.
Range-Based for with std::span and std::string_view
std::span and std::string_view are non-owning range wrappers — they already satisfy the range concept, so range-based for works on them directly without copying the underlying data.
// C++17 — non-owning string iteration
std::string s = "hello, world";
for (auto ch : std::string_view(s)) { // C++17
std::print("{} ", ch); // no allocation; iterates s's buffer
}
// C++20 — raw array wrapped in std::span
int arr[] = {10, 20, 30, 40, 50};
for (auto& x : std::span(arr, 5)) { // C++20
x *= 2; // modifies arr in-place through the span
}
// C++20 — subspan: iterate only a slice
std::vector<int> vec{0, 1, 2, 3, 4, 5};
for (auto x : std::span(vec).subspan(1, 3)) { // C++20 — elements 1, 2, 3
std::print("{} ", x);
}Both types are zero-copy by design — they hold a pointer and a length, and iterating them never triggers any heap activity. They are ideal as function parameters when the caller wants to accept any contiguous range (raw array, std::vector, std::array, or another span) without a template:
// C++20 — accept any contiguous range of int, read-only
void print_all(std::span<const int> view) {
for (auto x : view) std::print("{} ", x); // C++20
}
print_all(arr); // raw array
print_all(vec); // std::vector<int>Prefer std::span<const T> in function signatures over const std::vector<T>& — span accepts raw arrays and other contiguous containers that a vector reference would reject.
See Also
std::views::enumerate— index + value pairs (C++23)std::views::reverse,std::views::filter,std::views::transform— composable range adaptors (C++20)std::ranges::for_each— algorithm equivalent with projection support (C++20)std::default_sentinel_t— canonical sentinel tag type for heterogeneous iterator pairs (C++20)std::ranges::rangeconcept — compile-time check that a type satisfies the range requirements (C++20)- Structured bindings —
auto [a, b] = ...destructuring syntax (C++17)