C++20 consteval: Forcing Compile-Time Evaluation Where constexpr Only Allows It

Key takeaways

A consteval function must produce a constant at every call site, so passing it a runtime value is a compile error. Keep the logic in a constexpr function, put consteval at the API edge (literals, checked constructors, tables), and use if consteval (C++23) when a constexpr function needs to call it.

constexpr on a function means the function can run at compile time. Whether it actually does depends on each call. consteval, added in C++20, removes that choice: every call to a consteval function (an immediate function) must be evaluated during compilation, and a call that can’t be is a hard error. That makes it useful for rejecting bad input at build time and for things like format-string checking. It also has one sharp edge, around constexpr wrappers, that catches almost everyone.

Every example below was compiled with g++ 10.3 (-std=c++20 -Wall -Wextra). The diagnostics quoted are from that compiler unless stated otherwise. Features that need a newer compiler are labelled.

The difference in one example

constexpr int sq_ce(int x) { return x * x; }
consteval int sq_cv(int x) { return x * x; }

int main() {
    int a = 5;
    constexpr int k = sq_cv(5);   // OK: compile time
    int r = sq_cv(5);             // OK: still evaluated at compile time; r is just initialized with 25
    int y = sq_ce(a);             // OK: constexpr function, runtime call
    int z = sq_cv(a);             // error
}
error: the value of 'a' is not usable in a constant expression
note: 'int a' is not const

Clang says call to consteval function 'sq_cv' is not a constant expression, and MSVC reports C7595. All three compilers mean the same thing: the arguments of an immediate call have to be constants where the call is written.

Note the int r = sq_cv(5); line. The result doesn’t have to go into a constexpr variable. Any call whose arguments are constants is fine in ordinary runtime code, and the compiler replaces it with the value.

constexpr functionconsteval function
Runtime call allowedYesNo
Error if a call can’t be constantOnly where a constant is requiredAt every call site
Can take its address normallyYesOnly inside another immediate context
Appears in the binaryUsually, if called at runtimeNever as a callable function
Applies to variablesYes (constexpr int x)No (use constexpr or constinit)

The trap: constexpr wrappers

consteval int square(int x) { return x * x; }

constexpr int twice_square(int x) {
    return 2 * square(x);          // error: 'x' is not a constant expression
}

Many articles say “a constexpr function can’t call a consteval function”. That’s not quite the rule. This version compiles:

consteval int answer() { return 42; }
constexpr int wrapper() { return answer(); }   // fine: answer() is a constant by itself

The real rule: each call to an immediate function must be a constant expression by itself, unless it sits in an immediate function context (the body of another consteval function, or, from C++23, an if consteval branch). A constexpr function’s parameter isn’t a constant inside that function, because the function might run at runtime. So square(x) fails and answer() doesn’t.

The obvious workaround does not work:

constexpr int f(int x) {
    if (std::is_constant_evaluated()) return square(x);   // g++ 10: 'x' is not a constant expression
    return x * x;
}

std::is_constant_evaluated() is an ordinary runtime query. The immediate call inside the branch is still checked on its own. Here are the three real fixes:

  1. Make the wrapper consteval too, if it never needs to run at runtime.
  2. C++23 if consteval (GCC 12+, Clang 14+). Its first branch is an immediate function context, so if consteval { return square(x); } else { return x * x; } is valid. I couldn’t compile this with g++ 10.3.
  3. C++23 consteval propagation (P2564), adopted as a defect report and implemented in GCC 14 and Clang 17. A constexpr function (or function template specialization) that contains such a call is promoted to consteval automatically, instead of the code being rejected. On an older compiler the same code is an error, so code relying on this doesn’t build on older toolchains.

In practice, whenever I switched a hashing or parsing helper from constexpr to consteval, it spread up the call graph: every generic helper that forwarded the argument had to become consteval as well, until the change hit a function that also had runtime callers and could go no further. That’s why the pattern below, a constexpr core with a consteval entry point, is the one that holds up.

Pattern: constexpr core, consteval front door

The most common consteval mistake in tutorials is making a hash function consteval and then calling it on a runtime string in switch:

consteval unsigned djb2(const char* s);
void handle(const std::string& t) {
    switch (djb2(t.c_str())) { /* ... */ }    // error: call to non-'constexpr' function '... c_str() const'
}

The case labels need a compile-time hash, but the switch value is a runtime string. Write the algorithm once as constexpr. Then add a consteval wrapper for the places that must be compile time. Here that’s a user-defined literal, so a non-constant use can’t slip in:

#include <cstdint>
#include <cstdio>
#include <string_view>

constexpr std::uint32_t fnv1a(std::string_view s) {
    std::uint32_t h = 2166136261u;
    for (unsigned char c : s) { h ^= c; h *= 16777619u; }
    return h;
}

consteval std::uint32_t operator""_id(const char* s, std::size_t n) {
    return fnv1a({s, n});
}

void dispatch(std::string_view cmd) {
    switch (fnv1a(cmd)) {                     // runtime: constexpr function
    case "login"_id:  std::puts("login");  break;
    case "logout"_id: std::puts("logout"); break;
    default:          std::puts("unknown");
    }
}

dispatch("logout") prints logout, and an unknown command falls through to default. A hash switch still has to deal with collisions. If two case labels collide, the compiler rejects the duplicate case value, but a runtime string that happens to collide with a label needs a string comparison after the match if that matters to you.

Pattern: consteval constructors validate literals

This is the pattern the standard library itself relies on. The constructor of std::basic_format_string is consteval, and that’s how std::format("{:d}", "text") becomes a compile error instead of a runtime exception. The mechanism is simple: a throw that is actually reached during constant evaluation makes the evaluation fail.

class Port {
public:
    consteval Port(int p) : v_(p) {
        if (p < 1 || p > 65535) throw "port out of range";
    }
    constexpr int value() const { return v_; }
private:
    int v_;
};

void listen(Port p);

listen(8080);      // OK
listen(70000);     // error
in 'constexpr' expansion of 'Port(70000)'
error: expression '<throw-expression>' is not a constant expression
    |         if (p < 1 || p > 65535) throw "port out of range";

The error points at the throw line, so the thrown string works as the error message a user reads. On the valid path the throw is never reached, so it costs nothing. The trade-off is that Port can’t be built from a runtime value. If you need that too, add a separate named factory (Port::checked(int)) that validates at runtime. Don’t give the consteval constructor a constexpr overload: overloads can’t differ only in consteval-ness.

std::source_location::current() is also consteval. As a default argument it’s evaluated at each caller’s site, which is why void log(std::source_location loc = std::source_location::current()) reports the caller’s line.

Pattern: tables built at compile time, used at runtime

A consteval function can build data. A constexpr variable then stores the result, and runtime code reads it:

#include <array>
#include <cstdint>
#include <string_view>

consteval std::array<std::uint32_t, 256> make_crc_table() {
    std::array<std::uint32_t, 256> t{};
    for (std::uint32_t i = 0; i < 256; ++i) {
        std::uint32_t c = i;
        for (int k = 0; k < 8; ++k) c = (c & 1) ? 0xEDB88320u ^ (c >> 1) : c >> 1;
        t[i] = c;
    }
    return t;
}
constexpr auto crc_table = make_crc_table();

std::uint32_t crc32(std::string_view data) {
    std::uint32_t c = 0xFFFFFFFFu;
    for (unsigned char b : data) c = crc_table[(c ^ b) & 0xFF] ^ (c >> 8);
    return c ^ 0xFFFFFFFFu;
}
// crc32("123456789") == 0xCBF43926, the standard CRC-32 check value

Use unsigned types here. Versions of this example that use int hold 0xEDB88320 in a signed value, and depending on the compiler and standard the shifts give the wrong table or overflow.

Pattern: predicates in constraints

consteval bool is_prime(int n) {
    if (n < 2) return false;
    for (int i = 2; i * i <= n; ++i) if (n % i == 0) return false;
    return true;
}

template <int N> requires (is_prime(N))
struct PrimeTable { std::array<int, N> slots{}; };

PrimeTable<17> ok;
PrimeTable<16> bad;   // error: template constraint failure ... evaluated to 'false'

A constexpr function would do exactly the same job here, because a requires clause is a constant context anyway. Use consteval only when you want to forbid runtime use of the predicate.

What a consteval body can and cannot do

  • Allowed: loops, local variables, recursion (within the compiler’s limits), std::array, std::string_view, and since C++20 transient dynamic allocation. new int[n] followed by delete[] in the same evaluation compiles with g++ 10. constexpr std::vector/std::string need library support (libstdc++ 12, recent libc++ and MSVC STL).
  • Not allowed: calling non-constexpr functions. std::isalpha fails with 'isalpha(97)' is not a constant expression, so write your own c >= 'a' && c <= 'z'. Also not allowed: I/O, reinterpret_cast, reading non-constant globals, undefined behavior (signed overflow, out-of-bounds access), and memory still allocated when evaluation ends.
  • Address-taking: auto p = &square; in normal code fails with taking address of an immediate function 'consteval int square(int)'. A pointer to an immediate function can’t escape to runtime.
  • Lambdas: [](int x) consteval { return x * x; } is valid C++20, and the same rules apply to each call.

Compile-time cost

consteval doesn’t make evaluation slower or faster than constexpr. The same constant evaluator runs either way. The costs to watch are the ones of any constant evaluation: very deep recursion (GCC’s -fconstexpr-depth, default 512), long loops (-fconstexpr-loop-limit / -fconstexpr-ops-limit), and large generated tables that get re-evaluated in every translation unit that includes the header. When a big table slows the build, move it to a single .cpp file, or declare it inline constexpr in a header so it’s defined once instead of copied into every translation unit. If you’re not sure, measure with Clang’s -ftime-trace.

Compiler support

  • GCC 10+ supports consteval. All examples here compile with 10.3. if consteval needs GCC 12, and P2564 propagation needs GCC 14.
  • Clang 11+ accepts consteval, but some corner cases (default arguments, nested immediate calls) were only fixed in later releases, up to Clang 17, which also implements P2564. if consteval needs Clang 14.
  • MSVC: VS 2019 16.10 (19.29) and later.

The feature-test macro is __cpp_consteval (201811L for C++20, 202211L once P2564 is implemented). Checking for the propagation value is a better guide than checking compiler versions if you ship code that relies on it.

When to use which

  • Callers need both compile-time and runtime → constexpr.
  • The result must be known at build time, and a runtime call is a bug → consteval. Examples: literal validators, format-string checkers, ID literals, source_location-style capture.
  • A variable must be constant-initialized but may change later → constinit. For a variable that must be a compile-time constant → constexpr. consteval doesn’t apply to variables.
  • Generic library code that forwards arguments → keep it constexpr. Add consteval at the outermost user-facing API, so the requirement doesn’t spread to every helper.