Skip to content
C++
Language
since C++20
Advanced

Master Ranges and Views for Lazy, Composable Data Pipelines

Build lazy, composable data pipelines in C++20 using ranges and views, replacing verbose iterator pairs with readable, zero-overhead transformations.

By the end of this page, you will understand what a range and a view are, compose multi-step data pipelines using the | operator, avoid the most common lifetime and evaluation-order pitfalls, and replace iterator-pair boilerplate with readable, zero-overhead transformations.

What and Why

Before C++20, transforming a sequence required either raw loops or passing paired iterators everywhere:

cpp
#include <algorithm>
#include <vector>

std::vector<int> evens;
std::copy_if(v.begin(), v.end(), std::back_inserter(evens),
             [](int x){ return x % 2 == 0; });

This allocates, copies, and forces you to name an intermediate container you never wanted. Worse, if you need three transformations you create three temporaries.

C++20 ranges give the standard library a unified concept of "something you can iterate." A view is a lightweight, non-owning object that describes a transformation lazily β€” nothing is computed until you actually iterate the view. The pipeline operator | wires views together left-to-right, producing code that reads like a sentence.

The key insight: a view does not hold elements. It holds only the logic to produce elements on demand. Constructing a five-stage pipeline costs O(1) time and O(1) space regardless of input size.

Step by Step

Step 1 β€” Your first range algorithm

Range algorithms live in std::ranges:: and accept a single range object instead of an iterator pair.

cpp
#include <algorithm>
#include <ranges>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v = {5, 3, 1, 4, 2};
    std::ranges::sort(v);                        // no .begin()/.end() needed

    for (int x : v) std::cout << x << ' ';      // 1 2 3 4 5
}

std::ranges::sort is constrained by concepts β€” if you pass the wrong type you get a readable error, not a wall of template noise.

Step 2 β€” Your first view

cpp
#include <ranges>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v = {1, 2, 3, 4, 5, 6};

    auto evens = v | std::views::filter([](int x){ return x % 2 == 0; });

    for (int x : evens) std::cout << x << ' ';  // 2 4 6
}

evens is not a std::vector. It is a lightweight adaptor wrapping v. No heap allocation occurs. Elements are filtered one at a time as the for loop advances the iterator.

Step 3 β€” Chaining views

Views compose with | exactly like Unix pipes.

cpp
#include <ranges>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

    auto pipeline = v
        | std::views::filter([](int x){ return x % 2 == 0; })  // keep evens
        | std::views::transform([](int x){ return x * x; })    // square them
        | std::views::take(3);                                  // first three

    for (int x : pipeline) std::cout << x << ' ';  // 4 16 36
}

Each | returns a new view object; no element is produced until the final range-for iterates it.

Step 4 β€” Materialising a view into a container

When you do need a concrete container, use std::ranges::to (C++23) or std::ranges::copy:

cpp
#include <ranges>
#include <vector>
#include <algorithm>
#include <iterator>

int main() {
    std::vector<int> src = {1, 2, 3, 4, 5};

    // C++23 β€” cleanest
    // auto result = src | std::views::transform([](int x){ return x * 2; })
    //             | std::ranges::to<std::vector>();

    // C++20 β€” explicit copy
    std::vector<int> result;
    auto doubled = src | std::views::transform([](int x){ return x * 2; });
    std::ranges::copy(doubled, std::back_inserter(result));
    // result == {2, 4, 6, 8, 10}
}

Common Patterns

Pattern 1 β€” Filtering and projecting with range algorithms

Range algorithms accept a projection β€” a callable applied to each element before comparison β€” eliminating the need for awkward lambdas over member access.

cpp
#include <algorithm>
#include <ranges>
#include <string>
#include <vector>
#include <iostream>

struct Employee { std::string name; int salary; };

int main() {
    std::vector<Employee> staff = {
        {"Alice", 90000}, {"Bob", 60000}, {"Carol", 75000}
    };

    std::ranges::sort(staff, std::less<>{}, &Employee::salary);

    for (auto& e : staff) std::cout << e.name << ' ';  // Bob Carol Alice
}

The third argument to std::ranges::sort is the projection. No lambda wrapping needed β€” it reads like plain English.

Pattern 2 β€” Generating sequences with iota and building pipelines

std::views::iota produces an infinite (or bounded) sequence without any storage.

cpp
#include <ranges>
#include <iostream>

int main() {
    // Sum of squares of first ten odd numbers
    long long total = 0;
    for (int x : std::views::iota(1)
               | std::views::filter([](int n){ return n % 2 != 0; })
               | std::views::transform([](int n){ return n * n; })
               | std::views::take(10))
    {
        total += x;
    }
    std::cout << total << '\n';  // 1+9+25+...+361 = 1330
}

The infinite iota(1) is safe here because take(10) short-circuits iteration.

Pattern 3 β€” Splitting and joining strings

cpp
#include <ranges>
#include <string_view>
#include <iostream>

int main() {
    std::string_view csv = "apple,banana,cherry";

    for (auto token : csv | std::views::split(',')) {
        std::cout << std::string_view(token) << '\n';
    }
    // apple
    // banana
    // cherry
}

std::views::split returns a range of ranges. Each sub-range is non-owning and refers into the original string.

Pattern 4 β€” Index alongside element with std::views::enumerate (C++23)

When you need both the zero-based position and the element value, std::views::enumerate produces pairs without a manual counter variable:

cpp
#include <ranges>
#include <vector>
#include <iostream>

int main() {
    std::vector<std::string> fruits = {"apple", "banana", "cherry"};

    for (auto const [index, fruit] : std::views::enumerate(fruits)) {
        std::cout << index << ": " << fruit << '\n';
    }
    // 0: apple
    // 1: banana
    // 2: cherry
}

Each element is a std::tuple<std::ptrdiff_t, T&>. The structured binding [index, fruit] names them conveniently. This is the C++23 equivalent of Python's enumerate() and replaces the pattern of maintaining a i = 0; ++i counter alongside a range-for.

Pattern 5 β€” Map keys and values with std::views::keys / std::views::values (C++20)

Iterating over only the keys or only the values of a map no longer requires a structured binding that discards half the pair:

cpp
#include <ranges>
#include <map>
#include <iostream>

int main() {
    std::map<std::string, int> scores = {{"Alice", 90}, {"Bob", 75}, {"Carol", 85}};

    for (const auto& name : std::views::keys(scores))
        std::cout << name << '\n';           // Alice  Bob  Carol

    for (int s : std::views::values(scores))
        std::cout << s << '\n';              // 90  75  85
}

std::views::keys is equivalent to std::views::transform([](auto& p){ return p.first; }) but with clearer intent. Both are C++20 and work on any range of pair-like elements.

Pattern 6 β€” Zipping two ranges with std::views::zip (C++23)

Before C++23, iterating two containers in lockstep required a manual index or a while loop keeping two iterators in sync:

cpp
// Old approach β€” error-prone, must track min size manually
std::vector<int> prices = {100, 200, 150};
std::vector<int> costs  = {10,  20,  50};
for (std::size_t i = 0; i < std::min(prices.size(), costs.size()); ++i)
    std::cout << prices[i] - costs[i] << ' ';  // 90 180 100

std::views::zip (C++23) does this in one expression. It produces a view of std::pair (for two ranges) or std::tuple (for three or more), stopping at the shortest range:

cpp
#include <ranges>
#include <vector>
#include <iostream>

int main() {
    std::vector<std::string> names  = {"Alice", "Bob", "Carol"};
    std::vector<int>         scores = {90, 75, 85};

    for (auto const& [name, score] : std::views::zip(names, scores))
        std::cout << name << ": " << score << '\n';
    // Alice: 90
    // Bob:   75
    // Carol: 85
}

Structured bindings unpack the pair directly. For three ranges, zip yields std::tuple<T0&, T1&, T2&> β€” useful for Structure-of-Arrays (SOA) layouts where related data lives in separate vectors.

std::views::zip_transform(fn, r1, r2) is a companion that applies a callable element-wise rather than producing a tuple, acting as a lazy std::transform over multiple ranges simultaneously.

Pattern 7 β€” Chunking and Sliding Windows (C++23)

Two new adaptors handle the common need to process a range in fixed-size batches or overlapping windows.

std::views::chunk(n) splits a range into consecutive non-overlapping groups of n elements. The last group may be smaller if the range size is not divisible by n:

cpp
#include <ranges>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8};

    for (auto const& chunk : v | std::views::chunk(3)) {
        std::cout << '[';
        for (int x : chunk) std::cout << x << ' ';
        std::cout << "]\n";
    }
    // [1 2 3]
    // [4 5 6]
    // [7 8]
}

std::views::slide(n) produces overlapping windows of n consecutive elements β€” the "sliding window" pattern. Each element of the result view is itself a view into the original range:

cpp
#include <ranges>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v = {1, 2, 3, 4, 5};

    for (auto const& window : v | std::views::slide(3)) {
        for (int x : window) std::cout << x << ' ';
        std::cout << '\n';
    }
    // 1 2 3
    // 2 3 4
    // 3 4 5
}

A compile-time variant, std::views::adjacent<N>, produces tuples of N references instead of sub-views, allowing structured bindings like auto [a, b, c].

std::views::chunk_by(pred) is a predicate-based variant: it splits on every adjacent pair for which the binary predicate returns false, grouping runs of "related" elements together:

cpp
std::vector<int> v = {1, 1, 2, 3, 5, 5, 6};
// Group consecutive equal values
for (auto const& run : v | std::views::chunk_by(std::equal_to<>{})) {
    for (int x : run) std::cout << x << ' ';
    std::cout << '\n';
}
// 1 1
// 2
// 3
// 5 5
// 6

Pattern 8 β€” Joining a range of ranges with a separator using std::views::join_with (C++23)

std::views::split breaks a sequence into sub-ranges. std::views::join_with (C++23) is its inverse: it flattens a range of ranges into a single sequence and inserts a delimiter between each inner range.

cpp
#include <ranges>
#include <vector>
#include <string>
#include <string_view>
#include <iostream>

int main() {
    std::vector<std::string_view> words = {"apple", "banana", "cherry"};

    // join_with inserts the delimiter between each word
    for (char c : words | std::views::join_with(std::string_view(", ")))
        std::cout << c;
    // apple, banana, cherry
}

The delimiter can be a single element (',') or a range of elements (", "). To materialise the result into a std::string, use the std::from_range constructor tag (C++23):

cpp
std::string result(std::from_range,
                   words | std::views::join_with(std::string_view(" ")));
// result == "apple banana cherry"

join_with is the complement of split and composes naturally with transform: first transform each word, then join with a separator, all lazily with no intermediate allocations.

Writing a Custom View Adaptor

C++23 formalises the range adaptor closure object concept: any type that inherits from std::ranges::range_adaptor_closure<Derived> gains the | pipe operator for free, so it slots into pipelines just like library views.

The pattern: store the adaptor's configuration in the closure object, then implement operator() taking the incoming range:

cpp
#include <ranges>
#include <vector>
#include <functional>
#include <iostream>

// A reusable "take_while" adaptor that works with operator|
template <typename Pred>
struct take_while_closure
    : std::ranges::range_adaptor_closure<take_while_closure<Pred>>
{
    Pred pred;
    explicit take_while_closure(Pred p) : pred(std::move(p)) {}

    // Called when the user writes  range | take_while_closure{pred}
    template <std::ranges::viewable_range R>
    auto operator()(R&& r) const {
        return std::forward<R>(r) | std::views::take_while(pred);
    }
};

// Factory function β€” mirrors the style of standard library adaptors
auto take_while_positive() {
    return take_while_closure{[](int x){ return x > 0; }};
}

int main() {
    std::vector<int> v = {3, 1, 4, -1, 5, 9};

    for (int x : v | take_while_positive())
        std::cout << x << ' ';   // 3 1 4
}

Inheriting from std::ranges::range_adaptor_closure<T> (C++23, standardised from P2387) is the only requirement. The base class provides operator| overloads so that both range | adaptor and adaptor | adaptor compose correctly. Before C++23, this was done by manually writing friend auto operator|(R&& r, Closure c).

What Can Go Wrong

Dangling views from temporaries

A view does not own its elements. If the underlying container is destroyed before the view is iterated, you have undefined behaviour.

cpp
// WRONG β€” temporary destroyed before loop runs
auto bad_view = std::vector<int>{1, 2, 3} | std::views::filter([](int x){ return x > 1; });
for (int x : bad_view) { /* UB: vector is gone */ }

// CORRECT β€” keep the source alive
std::vector<int> data = {1, 2, 3};
auto safe_view = data | std::views::filter([](int x){ return x > 1; });
for (int x : safe_view) std::cout << x << ' ';

The rule: a view must not outlive its source range.

Mutating the source while iterating a cached view

Some views (notably std::views::filter) cache the begin iterator on first access for performance. Inserting or erasing from the source container after constructing the view invalidates that cache silently.

cpp
std::vector<int> v = {1, 2, 3, 4};
auto view = v | std::views::filter([](int x){ return x % 2 == 0; });

v.push_back(6);          // may invalidate internal cache
for (int x : view) { }  // potentially incorrect results

Reconstruct the view after mutating the source.

Expecting operator[] on every view

Not all views are random-access. A filter view is bidirectional at best; indexing it requires std::ranges::advance or materialising to a vector first.

cpp
auto filtered = v | std::views::filter([](int x){ return x > 2; });
// filtered[2];   // compile error β€” filter_view does not support operator[]

Check the range category (random_access_range, bidirectional_range, etc.) when you need indexed access.

Quick Reference

View / AlgorithmWhat it doesCategory
std::views::filter(pred)Keep elements satisfying predAdaptor
std::views::transform(fn)Apply fn to each elementAdaptor
std::views::take(n)First n elementsAdaptor
std::views::drop(n)Skip first n elementsAdaptor
std::views::iota(start)Infinite integer sequenceFactory
std::views::split(delim)Split range on delimiterAdaptor
std::views::reverseReverse traversalAdaptor
std::ranges::sort(r, cmp, proj)Sort with optional projectionAlgorithm
std::ranges::copy(r, out)Materialise view to outputAlgorithm
std::views::enumerate(r)(index, element) pairs (C++23)Adaptor
std::views::keys(r)First element of each pair (C++20)Adaptor
std::views::values(r)Second element of each pair (C++20)Adaptor
std::ranges::to<C>()Materialise to container C (C++23)Adaptor
std::views::zip(r1, r2, ...)Lockstep iteration as pairs/tuples (C++23)Adaptor
std::views::zip_transform(fn, ...)Element-wise transform over multiple ranges (C++23)Adaptor
std::views::chunk(n)Non-overlapping N-element batches (C++23)Adaptor
std::views::slide(n)Overlapping N-element windows (C++23)Adaptor
std::views::adjacent<N>Overlapping N-tuples, compile-time N (C++23)Adaptor
std::views::chunk_by(pred)Split on adjacent predicate boundary (C++23)Adaptor
std::views::join_with(delim)Flatten range of ranges with delimiter (C++23)Adaptor

Lifetime rule: views are non-owning β€” the source must outlive every view built on it.

Laziness rule: no work happens until you iterate; chaining views is free.

Projection rule: range algorithms accept a projection as a trailing argument, eliminating boilerplate member-access lambdas.

What's Next