C++20 Coroutines to C++23 std::generator
C++20 gave the language coroutines and no generator type. This guide writes one from scratch, promise type and all, then deletes it and uses C++23 std::generator instead. The hand-written half runs on GCC 13. The standard half does not, and iterating the same generator twice crashes on this box — both shown with the actual output.
The C++20 framework, no library type
C++20 added three keywords, co_await, co_yield, and co_return, and no type that uses them. A function that uses one of those keywords becomes a coroutine, and the compiler rewrites it into a state machine that can suspend and resume. The state machine needs a return type, and the standard library in C++20 ships none for the common case of yielding a sequence of values. The caller writes the return type. That is the whole gap this guide works through: first the handwritten type, then the C++23 type that replaces it.
The previous guide in this course built error-handling pipelines with std::expected. See C++23 std::expected Error-Handling Pipelines. The two guides share a shape: a C++20 framework that a C++23 library type completes. The coroutine half of the story is older, and the version gap is wider, because the standard generator needs a newer standard library than the one most Linux distributions shipped until recently.
A coroutine runs in four states. It is created, suspended, running, and done. Creation does not run the body. The first resume runs the body until the first suspension point. Each resume after that runs the body until the next suspension point or the end. A generator is a coroutine whose suspension points are co_yield statements, each producing one value. The caller asks for values one at a time, and the coroutine produces them lazily: values that are never requested are never computed. An infinite generator is therefore an ordinary function in this style, and the caller bounds it from outside, with a loop limit or a ranges view, instead of the generator knowing when to stop.
The example through this guide counts upward. The handwritten half counts to five. The standard half counts to five, walks a small tree, and then counts without end until a view stops it. The counting is trivial on purpose. The machinery around it is the subject.
The promise type and a minimal awaitable
Every coroutine return type carries a nested promise_type. The compiler creates a promise object when the coroutine starts, and the promise type answers the questions the compiler asks: what object to return to the caller, whether to suspend before the body runs, whether to suspend after the body finishes, what to do with a yielded value, what to do with a returned value, and what to do with an exception that escapes the body. A handwritten generator is mostly a promise type, plus a handle that owns the coroutine frame and an iterator that resumes it.
The smallest awaitable shows the other half of the contract. An awaitable needs three functions: await_ready says whether to suspend at all, await_suspend runs when the coroutine suspends, and await_resume runs when it resumes and produces the result of the co_await expression. The awaitable below always suspends once and produces nothing:
// A minimal awaitable: always suspends once, resumes with nothing.
struct SuspendOnce {
bool await_ready() const noexcept { return false; }
void await_suspend(std::coroutine_handle<>) const noexcept {}
void await_resume() const noexcept {}
};
The promise type for the task below is minimal in the same way. Its unhandled_exception calls std::terminate(). That is a teaching simplification, stated plainly: it is the simplest policy that keeps the example short, and it is not production policy. Production code stores the exception in the promise and throws it again where the caller collects the result, so the caller sees the failure instead of the process dying. If you copy this promise type into real code, that is the first line to change.
Hand-rolled Task and Generator
The task type is the smallest coroutine that returns a value. Its promise holds the result, its get_return_object builds the task from the promise, and its get resumes the coroutine until it finishes and reads the result. The coroutine body awaits the minimal awaitable once, then returns 42. The body runs only when get resumes it, because initial_suspend returns std::suspend_always. A task that started eagerly would return std::suspend_never there instead, and the body would run to the first suspension point during the call itself.
#include <coroutine>
#include <iostream>
struct Task {
struct promise_type {
int result = 0;
Task get_return_object() {
return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void unhandled_exception() { std::terminate(); }
void return_value(int value) { result = value; }
};
using handle_type = std::coroutine_handle<promise_type>;
handle_type handle{};
explicit Task(handle_type h) : handle(h) {}
Task(const Task&) = delete;
Task(Task&& other) noexcept : handle(other.handle) { other.handle = {}; }
~Task() {
if (handle) handle.destroy();
}
int get() {
while (!handle.done()) handle.resume();
return handle.promise().result;
}
};
Task compute() {
co_await SuspendOnce{};
co_return 40 + 2;
}
The generator type follows the same shape, with yield_value in place of return_value. Each co_yield stores the value in the promise and suspends. The iterator resumes the coroutine, reads the stored value, and stops when the coroutine reports done. The generator owns the coroutine frame: the move constructor takes the handle and clears the source, copying is deleted, and the destructor destroys the frame. The iterator is deliberately thin. It holds the handle and a done flag, resumes on increment, and compares equal to the default sentinel when the coroutine is done. Range-based for loops use exactly that shape: begin resumes to the first value, end is the sentinel, and each increment resumes to the next.
template <typename T>
class Generator {
public:
struct promise_type {
T value{};
Generator get_return_object() {
return Generator{
std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() noexcept { return {}; }
// Must suspend here: the frame stays alive until the owner
// destroys it. suspend_never would destroy the frame at return
// and the owner's destroy() would free it a second time.
std::suspend_always final_suspend() noexcept { return {}; }
void unhandled_exception() { std::terminate(); }
std::suspend_always yield_value(T v) {
value = v;
return {};
}
void return_void() {}
};
using handle_type = std::coroutine_handle<promise_type>;
handle_type handle{};
explicit Generator(handle_type h) : handle(h) {}
Generator(const Generator&) = delete;
Generator(Generator&& other) noexcept : handle(other.handle) {
other.handle = {};
}
~Generator() {
if (handle) handle.destroy();
}
struct iterator {
handle_type handle{};
bool done = true;
iterator& operator++() {
handle.resume();
done = handle.done();
return *this;
}
const T& operator*() const { return handle.promise().value; }
bool operator==(std::default_sentinel_t) const { return done; }
};
iterator begin() {
handle.resume();
return iterator{handle, handle.done()};
}
std::default_sentinel_t end() { return {}; }
};
Generator<int> count_up_to(int n) {
for (int i = 1; i <= n; ++i) co_yield i;
}
int main() {
std::cout << "task: " << compute().get() << '\n';
std::cout << "hand: ";
for (int v : count_up_to(5)) std::cout << v << ' ';
std::cout << '\n';
}
The two listings above are one program. It compiled and ran on GCC 13.3.0 at -std=c++20, and again on GCC 14.2.0 at -std=c++20, with no -fcoroutines flag on either. The output on both was:
task: 42
hand: 1 2 3 4 5
The final-suspend rule in the comment is the one trap in this code that does not announce itself. final_suspend decides what happens when the coroutine body finishes. With std::suspend_always, the frame stays suspended at the end, and the owner's destructor destroys it exactly once. With std::suspend_never, the frame destroys itself when the body returns, and the owner's destructor then destroys the same frame a second time. The program may appear to work and then fail later, in unrelated code, because the second free corrupts the heap. An owning coroutine type must suspend at the final point. A non-owning handle, one that never destroys the frame, is the only shape where suspend_never is safe, and this generator is not that shape.
std::generator: the same count, then the tree
C++23 standardizes the pattern above as std::generator, defined in <generator>. It presents a view of the elements a coroutine yields, resuming the coroutine each time the next element is requested. It models view and input_range. The co_yield std::ranges::elements_of(rng) form yields every element of another range in turn, which is how a recursive traversal delegates to its children without writing its own iterator stack.
The program below is the standard version of the handwritten one, plus the tree and the range pipelines from the next section. The counting function is unchanged apart from its return type: std::generator<int> replaces Generator<int>, and the promise type, handle, and iterator disappear.
#include <generator>
#include <iostream>
#include <ranges>
#include <vector>
std::generator<int> count_up_to(int n) {
for (int i = 1; i <= n; ++i) co_yield i;
}
std::generator<int> count_from(int start) {
int i = start;
while (true) co_yield i++;
}
template <typename T>
struct Tree {
T value;
Tree* left{};
Tree* right{};
std::generator<const T&> traverse_inorder() const {
if (left) co_yield std::ranges::elements_of(left->traverse_inorder());
co_yield value;
if (right) co_yield std::ranges::elements_of(right->traverse_inorder());
}
};
int main() {
std::cout << "std count: ";
for (int v : count_up_to(5)) std::cout << v << ' ';
std::cout << '\n';
Tree<char> tree[]{
{'D', tree + 1, tree + 2},
{'B', tree + 3, tree + 4},
{'F', tree + 5, tree + 6},
{'A'},
{'C'},
{'E'},
{'G'},
};
std::cout << "tree: ";
for (char c : tree->traverse_inorder()) std::cout << c << ' ';
std::cout << '\n';
std::cout << "take: ";
for (int v : count_from(0) | std::views::take(10)) std::cout << v << ' ';
std::cout << '\n';
std::cout << "filter+take: ";
auto is_even = [](int v) { return v % 2 == 0; };
for (int v : count_from(1) | std::views::filter(is_even) |
std::views::take(5))
std::cout << v << ' ';
std::cout << '\n';
std::cout << "transform: ";
for (int v : count_up_to(3) |
std::views::transform([](int v) { return v * v; }))
std::cout << v << ' ';
std::cout << '\n';
std::vector<int> collected;
for (int v : count_up_to(3) | std::views::take(2)) collected.push_back(v);
std::cout << "collected: " << collected.size() << ' ' << collected[0] << ' '
<< collected[1] << '\n';
}
The tree is the example cppreference uses for std::generator, with the same shape and the same expected output. Each node yields its left subtree through elements_of, then its own value, then its right subtree. The traversal needs no explicit stack and no iterator type of its own: the stack of suspended coroutines is the traversal stack, and the destructor of the outer generator destroys the whole stack when iteration ends. The program compiled and ran on GCC 14.2.0 at -std=c++23, with no -fcoroutines flag. Compiling the same program with -fcoroutines also passed, so the flag is accepted but not needed on this compiler. The output was:
std count: 1 2 3 4 5
tree: A B C D E F G
take: 0 1 2 3 4 5 6 7 8 9
filter+take: 2 4 6 8 10
transform: 1 4 9
collected: 2 1 2
The same program does not compile on GCC 13.3.0 at -std=c++23. The failure is the missing header, not a language error:
std_generator.cpp:3:10: fatal error: generator: No such file or directory
3 | #include <generator>
| ^~~~~~~~~~~
compilation terminated.
That is the version gap in one error. The coroutine keywords work on GCC 13, and the handwritten generator in the previous section proves it. The standard generator type lives in the standard library, and the library on GCC 13 does not ship it. Upgrading the compiler without upgrading the library changes nothing here, and neither does adding a flag. The header arrives with GCC 14.
Generator as a range
Because std::generator models view and input_range, it composes with the ranges views in <ranges>. The infinite generator in the program above is the clearest case. count_from(0) never ends on its own. Piped through std::views::take(10), it produces ten values and stops, because take stops asking after ten. The coroutine never runs past the tenth value, and no values are computed in advance. The filter pipeline works the same way: count_from(1), filtered to even numbers, taking five, pulls values until five even ones have appeared, and the odd values in between are computed and discarded by the pipeline, one at a time. The transform pipeline squares each value as it is pulled. None of these pipelines materializes a container. The collected block at the end of the program is the one place a container appears, and it holds only the two values the take(2) view produced.
The input-range part of the model is a limit, not a detail. An input range is single-pass: it can be traversed once, and its iterators cannot be copied and used independently the way forward iterators can. A generator object must therefore be treated as a consumable, not as a reusable container. The program below, kept separate from the main program so its failure could be observed on its own, iterates the same generator object twice:
#include <generator>
#include <iostream>
std::generator<int> count_up_to(int n) {
for (int i = 1; i <= n; ++i) co_yield i;
}
int main() {
auto g = count_up_to(3);
int first = 0;
for (int v : g) {
++first;
std::cerr << "first:" << v << std::endl;
}
std::cerr << "first-count=" << first << std::endl;
std::cerr << "about second begin" << std::endl;
int second = 0;
for (int v : g) {
++second;
std::cerr << "second:" << v << std::endl;
}
std::cerr << "second-count=" << second << std::endl;
}
The first loop printed first:1, first:2, first:3, and first-count=3. The second loop printed nothing. The program crashed at the second begin, with a segmentation fault, exit status 139, before any second value was produced. That crash is the observed behavior of libstdc++ 14.2.0 on this box, and this guide does not present it as a rule of the standard: the standard promises a single pass, and what happens on a second pass is the implementation's business. The practical rule is the same either way. Never iterate a generator twice. Call the generator function again for a fresh generator object. If a sequence must be traversed more than once, collect it into a container first, as the collected block does, and traverse the container.
Compiler matrix and the feature-test guard
The version facts, as tested on this box and as recorded in the cppreference compiler-support table, are:
| Compiler | Result |
|---|---|
GCC 13.3.0, -std=c++20 |
Handwritten Task and Generator pass. std::generator fails: <generator> absent. |
GCC 13.3.0, -std=c++23 |
Same failure. The header is absent, not merely unguarded. |
GCC 14.2.0, -std=c++23 |
The full standard program passes, with and without -fcoroutines. |
Clang 18.1.3 with libstdc++ 13, -std=c++23 |
Fails. The <generator> header is present but empty, for the same reason the <expected> header was empty in the previous guide: Clang reports __cpp_concepts as 201907L, below the 202002L guard the library requires. |
Clang 18.1.3 with libstdc++ 14, -std=c++23 |
Fails differently. The header is found, but the compiler reports no template named 'generator' in namespace 'std', and the guard program below reports the header present with the macro absent. |
| Clang with libc++ | Not tested. No libc++ is installed on the build box, and the cppreference table cell for std::generator under Clang libc++ is blank. No Clang path is claimed in this guide. |
| MSVC | Version 19.43 in the cppreference table. Not tested on this box, and no MSVC result is claimed. |
The pairing is the fact, not the compiler name alone. Clang with libstdc++ 13 and Clang with libstdc++ 14 fail for the same underlying reason, through different errors, and a Clang version number by itself says nothing about whether <generator> compiles. The same pairing decided the <expected> results in the previous guide. Check the pairing on the machine in front of you before writing the compiler floor into a build script.
The guard program below settles the pairing in one compile. It prints the feature-test macro when the header is usable, and says plainly when it is not. Guard the generator code with it, so a compiler with the class template name but without the feature fails with a readable message instead of a missing-member error deep in a pipeline:
#include <iostream>
#if __has_include(<generator>)
#include <generator>
#endif
#if __has_include(<expected>)
#include <expected>
#endif
int main() {
#if defined(__cpp_lib_generator)
std::cout << "generator " << __cpp_lib_generator << '\n';
#elif __has_include(<generator>)
std::cout << "header present, macro absent\n";
#else
std::cout << "no generator\n";
#endif
#if defined(__cpp_lib_expected)
std::cout << "expected " << __cpp_lib_expected << '\n';
#else
std::cout << "no expected\n";
#endif
}
On this box the guard printed no generator and expected 202211 under GCC 13.3.0, generator 202207 and expected 202211 under GCC 14.2.0, and header present, macro absent with no expected under Clang 18.1.3 with the GCC 14 toolchain. The 202207L value is the one cppreference records for std::generator, and the 202211L value is the monadic std::expected value from the previous guide, shown here only to confirm the same guard program reads both headers.
One build-box note, because the GCC 14 result needs explaining. The box runs Ubuntu 24.04 with GCC 13 as its system compiler and no sudo access. GCC 14.2.0 was obtained by downloading Ubuntu's own g++-14 packages for that release and extracting them into a user directory, without installing them system-wide. The compiler that ran the standard program is the stock Ubuntu 14.2.0 build, and the extraction changes only where its files sit. A reader with a normal g++-14 install has the same compiler. The extraction is recorded here so the result can be reproduced on a box in the same state, not as a recommended install method.
Frequently asked questions
Why write the handwritten generator at all, if the standard one exists?
Because the handwritten one is what the standard one replaces, and its parts explain the standard one's behavior. The promise type explains why co_yield suspends. Handle ownership explains the final-suspend rule. The single-pass iterator explains why a generator cannot be traversed twice. A reader who has only used std::generator meets those behaviors as surprises. A reader who has written the handwritten type meets them as consequences.
Does std::generator work with -std=c++20?
It is a C++23 library type, in the <generator> header, and this guide tested it only at -std=c++23. The C++20 half of the guide is the handwritten type, which is the correct tool when the project or the compiler is still on C++20. Moving to the standard type is part of moving the project to C++23, not a change to make inside a C++20 build.
Can a generator await asynchronous work?
Not in the pattern this guide teaches. std::generator is the synchronous coroutine generator for ranges: it yields values to a range-based loop, one at a time, on the calling thread. Asynchronous coroutines, with tasks and executors that resume on other threads, are a different design, and the next guide in this course does not cover them. Do not put an asynchronous await inside a generator body and expect the range loop to wait for it.
What should I do when the guard prints "header present, macro absent"?
Read it as the pairing failing, not the header missing. The header file exists, but the library judged the compiler unsuitable and left the type undefined. The fix is a different compiler-and-library pairing, not a different include path or a compiler flag. On this box, that state came from Clang 18 with either libstdc++ version, and no flag changed it.
What comes next in this course?
The next guide in course order is C++23 std::mdspan and Deducing This: A Matrix and Image View Library. It builds a matrix and image view library over one flat buffer, and it needed GCC 16 on the build box. The single-pass idea does not carry over: unlike a generator, an mdspan view can be read repeatedly, because it is a mapping over a buffer, not a coroutine.
Source
- cppreference, std::generator — the class template, the
viewandinput_rangemodel,beginandend,elements_ofdelegation, the tree-traversal example, and the__cpp_lib_generatorvalue202207L. - cppreference, Compiler support for C++23 — the library-features table:
std::generatorin GCC libstdc++ 14, no version recorded for Clang libc++, MSVC STL 19.43.
Facts verified 2026-11-04. Every code listing on this page was compiled and run on Ubuntu 24.04, including the programs that fail: the handwritten program with GCC 13.3.0 at -std=c++20 and GCC 14.2.0 at -std=c++20; the standard program with GCC 14.2.0 at -std=c++23, with and without -fcoroutines, and the failing GCC 13.3.0 and Clang 18.1.3 attempts. The error messages are quoted from the actual compiler output, and the failing programs are labeled as failing. The single-pass crash is the observed behavior of the second iteration on GCC 14.2.0 with libstdc++ 14.2.0, reported as an observed crash, not as a standard rule. MSVC and Clang with libc++ were not tested.
Keep Exploring
Keep exploring the docs, samples, and free tools to continue your evaluation.
Score SPF, DKIM, DMARC, MTA-STS, TLS-RPT, and BIMI for any domain.
Review grounded audit, compare, fix-plan, and report excerpts before you wire the API into anything.
Jump from the workflow page into the quickstart, endpoint guides, and full OpenAPI reference.