C++ Range-Based for: auto, References, and Temporaries
Key takeaways
How to choose auto, auto&, and const auto& in range-for; pitfalls with temporaries and proxy iterators; pairing with C++17 structured bindings; custom begin/end; and practical patterns.
What is range-based for?
Range-based for (range-based for, C++11) is syntax for walking an entire sequence without writing indices or iterators by hand: you take one element at a time from a range.
std::vector<int> v = {1, 2, 3};
for (int x : v) {
std::cout << x << '\n';
}
Roughly, the loop uses iterators from begin(v) / end(v), and at each step the result of dereferencing is assigned to the loop variable.
for (auto&& __range = (v); ; ) {
auto __begin = begin(__range);
auto __end = end(__range);
for (; __begin != __end; ++__begin) {
int x = *__begin; // depends on the declaration form
// ...
}
}
The exact rules follow the standard’s “range-based for statement” clause. It pairs well with the general loop guide.
Three details of this expansion explain most of the behaviour described below. The range expression is bound once to auto&& __range, so it is evaluated a single time and, if it is a temporary, that temporary lives until the loop ends. __end is computed once before the loop starts, which is why inserting into a vector during the loop is undefined behaviour rather than “the loop sees the new element”. And since C++17, __begin and __end may have different types, which is what allows ranges and views whose end is a sentinel rather than an iterator.
auto vs auto& vs const auto&
auto (by value)
Creates a copy of each element. Mutating x does not change the underlying container. That is cheap for small types like int and double.
for (auto x : vec) {
x *= 2; // elements of vec are unchanged
}
auto& (non-const reference)
An alias to the element. Mutations affect the original. A const container or const elements may make this ill-formed.
for (auto& x : vec) {
x *= 2; // elements of vec change
}
const auto& (const reference)
Widely used for read-only access without copying. Temporaries can be bound safely because lifetime extends to the loop body.
for (const auto& s : get_strings()) {
std::cout << s; // OK even if get_strings() returns a temporary
}
Choosing a form
| Goal | Suggestion |
|---|---|
| Read-only, large type | const auto& |
| Mutate elements | auto& (non-const range) |
| Cheap copy semantics | auto (small POD-like types) |
| Forwarding / generic signatures | auto&& (common in template code) |
auto&&: As a forwarding reference, it binds according to the range’s value_type and reference collapsing for lvalues vs rvalues. Template libraries use this often.
for (auto&& e : container) {
// e binds as lvalue ref or rvalue ref
}
A related hidden copy appears when the element type is spelled out and does not match exactly. A std::map<int, std::string> holds std::pair<const int, std::string>, so for (const std::pair<int, std::string>& p : m) does not bind to the elements at all: each element is converted into a temporary pair<int, std::string>, the reference binds to that, and every iteration copies the string. It compiles without a warning on most compilers (Clang has -Wrange-loop-construct for it). const auto& cannot make this mistake, which is the practical argument for auto here.
Temporary objects
When the range expression is a temporary
Under C++11 and later, if the range expression is a temporary, its lifetime is extended for the entire loop. So the following is safe:
for (const auto& x : make_vector()) { /* ... */ }
The extension applies only to the temporary that is the range expression, and this is the most dangerous rule in the whole feature:
struct Config { std::vector<std::string> items; const std::vector<std::string>& get() const { return items; } };
Config load();
for (const auto& s : load().get()) { // ❌ before C++23: dangling
std::cout << s;
}
Here the range expression is load().get(), a reference to a member of the temporary Config. __range binds to that reference, but the Config itself is destroyed at the end of the full expression that initializes __range, before the first iteration. The loop then reads a destroyed vector: often it prints garbage or crashes, sometimes it appears to work. C++23 (P2718) fixed this by extending the lifetime of all temporaries in the range expression, but code compiled as C++20 or earlier is still affected. The safe habit is to name the object first: auto cfg = load(); for (const auto& s : cfg.get()). Returning the container by value from get() also works, since the returned vector is then the temporary that gets extended.
vector<bool> and proxy references
The std::vector<bool> specialization yields a proxy object, not a real bool&. for (auto& b : bits) therefore does not compile (a non-const lvalue reference cannot bind to the temporary proxy); for (auto&& b : bits) binds to the proxy and assigning b = true writes through to the bit. Generic code that assumes std::vector<T>::reference is T& breaks when T is bool.
Invalidation
If you reallocate or insert in the container during iteration, iterators break. Range-based for uses iterators internally, so the same rules apply.
for (int x : v) {
if (x == 0) v.push_back(1); // ❌ undefined behaviour: may reallocate, and __end is stale anyway
}
The loop cannot see the problem, because __begin and __end were captured before the first iteration. When you need to add or remove elements, use an index loop for appends, std::erase_if (C++20) or the erase–remove idiom for removals, or collect changes in a second container and apply them after the loop.
Bad pattern (reference outlives the range)
const std::string* p = nullptr;
{
std::vector<std::string> v = {"a"};
for (const auto& s : v) {
p = &s; // do not use p outside the loop
}
} // v destroyed
// *p // undefined behavior
Lifetime extension for a temporary range is only guaranteed inside that for statement; escaping a pointer/reference to elements past the loop is still unsafe.
Structured bindings (C++17)
With structured bindings, you can unpack pair, tuple, map::value_type, and similar types in one step while iterating.
std::map<int, std::string> m;
for (const auto& [key, val] : m) {
std::cout << key << ": " << val << '\n';
}
Caution: the elements of a std::map are std::pair<const Key, T>, so with auto& [k, v] the key k is const and cannot be modified, while v can. auto [k, v] (no &) copies the whole pair on every iteration, which for a map of strings is an easy way to lose performance. Use const auto& [k, v] for reading and auto& [k, v] when you update values in place.
std::vector<std::pair<int, int>> pairs = {{1,2},{3,4}};
for (auto [a, b] : pairs) { // copy
std::cout << a << b;
}
for (auto& [a, b] : pairs) { // references; can mutate
++a;
}
You can combine this with C arrays and struct members in the same style.
Custom types: begin / end
Range-based for finds begin / end via ADL (argument-dependent lookup). It works if:
std::begin(x)/std::end(x)are valid, orx.begin()/x.end()exist, or- Non-member
begin(x)/end(x)exist in an associated namespace.
// type definition
struct MyRange {
int* data;
size_t n;
int* begin() { return data; }
int* end() { return data + n; }
};
MyRange r = ...;
for (int x : r) { /* ... */ }
Non-member example:
struct Buffer;
const int* begin(const Buffer& b);
const int* end(const Buffer& b);
Const correctness: for const objects you need begin / end overloads that work on const. MyRange above only has non-const members, so for (int x : std::as_const(r)) or iterating a const MyRange& parameter fails with an error like passing 'const MyRange' as 'this' argument discards qualifiers. Adding const int* begin() const and const int* end() const fixes it. The lookup order matters too: if the type has any member named begin or end, the compiler uses the members and never looks for free functions, even when the members are unsuitable.
Practical patterns
When you need an index
Use C++23 std::views::enumerate (for (auto [i, x] : std::views::enumerate(vec))), a classic index for, or a separate counter. With a separate counter, keep the ++i at the end of the body and avoid continue, which would skip it.
size_t i = 0;
for (const auto& x : vec) {
use(i, x);
++i;
}
initializer_list and temporaries
for (int x : {1, 2, 3}) { }
A braced list creates a temporary std::initializer_list<int>, whose backing array lives for the whole loop, so this is safe. Two limits: all elements must have the same type ({1, 2.5} fails to deduce), and the elements are const, so for (auto& x : {a, b, c}) iterates over copies of a, b, c, not the variables themselves. To modify several variables in one loop, iterate over pointers (for (int* p : {&a, &b, &c}) *p = 0;) or std::reference_wrapper.
Reverse iteration
Range-based for is not reverse. If rbegin / rend exist:
for (auto it = vec.rbegin(); it != vec.rend(); ++it) { }
// or C++20 ranges reverse_view
for (int x : vec | std::views::reverse) { }
const containers and intent to mutate
void print(const std::vector<int>& v) {
for (int x : v) { } // copy
for (const auto& x : v) { } // preferred for read-only
}
Readability: long range expressions
for (const auto& item : obj.get_container().get_items()) {
// get_container() is not called each iteration (range is evaluated once)
}
Per the standard, the range expression is evaluated once. That is only safe here if obj is a named object that outlives the loop; if the chain starts from a function returning by value, it is exactly the dangling case from the temporaries section.
Relation to C++20 std::ranges
C++20 std::ranges composes naturally with range-based for when you use views (lazy sequences).
#include <ranges>
// example
std::vector<int> v = {1, 2, 3, 4, 5};
for (int x : v | std::views::filter([](int n) { return n % 2 == 0; })) {
std::cout << x << ' ';
}
Here the entire piped expression is the “range”; views are usually cheap to pass by value. If the range expression’s type models the ranges concepts, begin / end resolution follows the extended rules (see the ranges reference when your project is on C++20).
Views are lazy and hold references to the underlying range, so the same lifetime rule applies: for (int x : make_vector() | std::views::filter(pred)) is fine because the pipe expression keeps the temporary vector alive inside an owning view, but storing a view of a temporary in a variable and looping over it later is not. filter_view also caches its first begin(), so it must not be iterated as const, and modifying elements in a way that changes the predicate’s result while iterating leads to surprising (undefined) behaviour.
vector<bool> in depth (proxy)
std::vector<bool> is a packed-bit specialization: operator[] returns a proxy, not a real reference to bool. Generic code that assumes std::vector<T>::reference is T& can fail for T == bool; in generic code consider treating vector<bool> specially or using std::deque<bool> / std::vector<char>. In range-based for, bool b and const auto& b work for reading and auto&& b works for assigning through the proxy, but auto& b does not compile.
This is also why auto&& is the default in generic code: it is the one form that binds to anything the iterator returns, real references, proxies and prvalues from views such as std::views::transform, without copying and without a compile error.
Which loop variable to write
| Topic | Takeaway |
|---|---|
auto | Copy of element; original unchanged |
auto& / const auto& | Alias; mutate vs read-only |
| Temporaries | Range temporary lifetime extended for the loop; do not leak references out |
| Structured bindings | Handy for maps, pairs, tuples |
| Custom types | begin/end or member begin/end |
Related posts: structured bindings, auto keyword and type deduction.
Frequently Asked Questions (FAQ)
Q. Why does for (auto& b : v) fail to compile when v is a std::vector?
A. std::vector<bool> stores packed bits, so dereferencing its iterator returns a temporary proxy object instead of a real bool&, and a non-const lvalue reference cannot bind to a temporary. Use auto&& if you want to modify elements through the proxy, or bool b / const auto& for read-only iteration. The same error appears with other ranges whose iterators return proxies rather than references.