Skip to content
C++
Language
since C++11
Basic

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++11

A 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; begin and end must return the same type.
  • C++17 — begin and end may have different types (sentinel support); structured bindings integrate cleanly inside the loop variable declaration.
  • C++20 — init-statement syntax added; std::default_sentinel_t standardised; std::views::reverse and other range adaptors available.
  • C++23 — std::views::enumerate provides indexed iteration without a manual counter.

Syntax

cpp
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):

cpp
// 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

cpp
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

cpp
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).

cpp
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 5

Sentinel pattern — null-terminated string

Sentinels avoid computing a past-the-end pointer, which is impossible for null-terminated strings and stream-like sources:

cpp
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 o

std::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:

cpp
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:

cpp
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:

cpp
std::vector<std::string> dst;
for (auto& s : src) dst.push_back(std::move(s));  // moves string buffers, no copy

Common Pitfalls

Iterator invalidation during the loop

cpp
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

cpp
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

cpp
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].first

std::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:

cpp
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 correctly

Chained member calls on a temporary range

auto&& extends only the top-level temporary produced by the range expression:

cpp
// 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 carefully

Range-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.

cpp
// 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:

cpp
// 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.

cpp
// 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:

cpp
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:

cpp
// 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 5

For types you cannot modify (third-party or legacy), provide free functions via ADL:

cpp
// 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++11

The 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.

cpp
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):

cpp
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:

cpp
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.

cpp
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:

cpp
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 ref

Rule 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.

cpp
// 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:

cpp
// 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::range concept — compile-time check that a type satisfies the range requirements (C++20)
  • Structured bindings — auto [a, b] = ... destructuring syntax (C++17)