C++Performance

C++20 constexpr vs consteval Explained (2026)

constexpr can run at compile time or runtime. consteval can never run at runtime, period. if constexpr branching and lookup tables shown. 2026.

TT
Emily Ross
••9 min read•Updated Aug 27, 2026
C++20 constexpr vs consteval Explained (2026)

C++ Compile-Time Programming: constexpr, consteval, constinit & Template Metaprogramming (C++23)


Table of Contents


The Four Compile-Time Keywords


constexpr Functions: Rules and Restrictions

A constexpr function can execute at compile time if called with constant expressions. If called with runtime values, it runs normally as a runtime function:

cpp
#include <array>
#include <numeric>

// Basic constexpr math:
constexpr long long factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
}

// Compile-time: result baked into binary as constant 120
constexpr long long f5 = factorial(5);   // 120 - computed at build time!
static_assert(f5 == 120);               // Verified at compile time!

// Runtime: computed normally
int n;
std::cin >> n;
long long fn = factorial(n);            // Runtime calculation - works too!

// C++14+: constexpr functions can have loops, local vars, conditionals:
constexpr 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;
}
constexpr bool p7  = is_prime(7);   // true  - compile time
constexpr bool p10 = is_prime(10);  // false - compile time

// C++20: constexpr functions can use:
// - try/catch (but not with actual exceptions at compile time)
// - dynamic_cast and typeid
// - std::vector, std::string (with constexpr allocator)
// - virtual function calls

Rules for constexpr functions:

  • No goto, thread-local storage, or I/O (printf, fopen, etc.)
  • No undefined behavior - the compiler catches it
  • Can call only constexpr functions
  • Local variables of non-literal types require C++14+

constexpr Classes and Objects (C++14/20)

cpp
// constexpr class - all member functions must be constexpr:
class FixedPoint {
    int value_; // stored as integer with implicit decimal
public:
    constexpr explicit FixedPoint(double v, int decimals = 2)
        : value_(static_cast<int>(v * std::pow(10, decimals))) {}
    
    constexpr FixedPoint operator+(const FixedPoint& o) const {
        FixedPoint result{0.0};
        result.value_ = value_ + o.value_;
        return result;
    }
    
    constexpr double to_double() const { return value_ / 100.0; }
};

constexpr FixedPoint price1{19.99};
constexpr FixedPoint price2{5.50};
constexpr FixedPoint total = price1 + price2; // Computed at compile time!
static_assert(total.to_double() == 25.49);

// constexpr Point - common pattern:
struct Point {
    double x, y;
    
    constexpr double distance_from_origin() const {
        // std::sqrt is constexpr since C++26 (use __builtin_sqrt for now):
        return x * x + y * y; // Return squared for now
    }
    
    constexpr Point operator+(const Point& o) const { return {x+o.x, y+o.y}; }
};

constexpr Point origin{0.0, 0.0};
constexpr Point p1{3.0, 4.0};
constexpr double dist_squared = p1.distance_from_origin(); // 25.0 - compile time
static_assert(dist_squared == 25.0);

consteval: Mandatory Compile-Time Functions (C++20)

consteval ("immediate function") forces execution at compile time. If the compiler cannot evaluate the call at compile time (because arguments aren't constant expressions), it's a hard compile error:

cpp
#include <cstdint>

// consteval: guaranteed compile-time format string validation
consteval uint32_t make_fourcc(char a, char b, char c, char d) {
    return uint32_t(a) | (uint32_t(b) << 8) | (uint32_t(c) << 16) | (uint32_t(d) << 24);
}

constexpr uint32_t RIFF = make_fourcc('R','I','F','F'); // Compile-time constant!
constexpr uint32_t WAVE = make_fourcc('W','A','V','E');

// make_fourcc cannot be called with runtime args:
char ch = 'X';
// make_fourcc(ch, 'B', 'C', 'D'); // COMPILE ERROR: ch is not constexpr

// Use case: validated format strings
consteval const char* validate_sql(const char* sql) {
    // Scan for obvious injection patterns at compile time:
    for (const char* p = sql; *p; p++) {
        if (*p == ';') throw "SQL contains semicolon - possible injection!";
    }
    return sql;
}

constexpr auto safe_query = validate_sql("SELECT id FROM users WHERE name = ?");
// constexpr auto bad_query  = validate_sql("SELECT id; DROP TABLE users"); // COMPILE ERROR!

// consteval for cross-platform diagnostics:
consteval int require_64bit_platform() {
    if (sizeof(void*) != 8)
        throw "This library requires a 64-bit platform!";
    return sizeof(void*);
}
static_assert(require_64bit_platform() == 8);

constinit: Initialization Order Guarantee (C++20)

The "Static Initialization Order Fiasco" occurs when one global's initialization depends on another global that may not be initialized yet:

cpp
// PROBLEM: static initialization order fiasco
// File1.cpp:
int global_value = compute_value(); // May run before File2.cpp's globals!

// File2.cpp:
extern int global_value;
int derived = global_value * 2; // May use uninitialized global_value!

// SOLUTION 1: constinit - forces constant initialization, no runtime init needed
constinit int global_value = 42; // Guaranteed to be constant-initialized
// constinit extern int other_global; // Mark declaration constinit too

// SOLUTION 2: function-local static (lazy initialization - always safe)
int& get_global() {
    static int value = compute_value(); // Initialized on first call, once
    return value;
}

// constinit rules:
// - Value must be computable at compile time (constant expression)
// - Variable must have static or thread-local storage duration
// - Does NOT make the variable const (can still be modified after init)

constinit thread_local int tls_counter = 0; // Thread-local constant init
tls_counter++; // Can be mutated after init - only init is guaranteed

Compile-Time Lookup Tables with IIFE

Generate lookup tables entirely at compile time - binary contains pre-computed data:

cpp
#include <array>
#include <cstdint>

// CRC32 lookup table - computed at build time, zero runtime cost
constexpr uint32_t crc32_byte(uint32_t byte) {
    uint32_t crc = byte;
    for (int i = 0; i < 8; i++) {
        crc = (crc & 1) ? (crc >> 1) ^ 0xEDB88320u : (crc >> 1);
    }
    return crc;
}

constexpr auto CRC32_TABLE = []() {
    std::array<uint32_t, 256> table{};
    for (int i = 0; i < 256; i++) {
        table[i] = crc32_byte(static_cast<uint32_t>(i));
    }
    return table;
}(); // IIFE: immediately invoked lambda at compile time

// At runtime, CRC32_TABLE is just an initialized const array in .rodata:
uint32_t crc32(const uint8_t* data, size_t len) {
    uint32_t crc = 0xFFFFFFFF;
    for (size_t i = 0; i < len; i++) {
        crc = CRC32_TABLE[(crc ^ data[i]) & 0xFF] ^ (crc >> 8);
    }
    return crc ^ 0xFFFFFFFF;
}

// Other examples of compile-time tables:
constexpr auto POPCOUNT_TABLE = []() {  // Population count table
    std::array<uint8_t, 256> t{};
    for (int i = 0; i < 256; i++) {
        t[i] = static_cast<uint8_t>(__builtin_popcount(i));
    }
    return t;
}();

constexpr auto SIN_TABLE = []() {  // Sine lookup (0-90 degrees, 91 entries)
    std::array<float, 91> t{};
    for (int i = 0; i <= 90; i++) {
        t[i] = static_cast<float>(i) * 3.14159265f / 180.0f;
    }
    return t;
}();

Type Traits and std::integral_constant

Type traits let you query properties of types at compile time:

cpp
#include <type_traits>

// Common type traits:
static_assert(std::is_integral_v<int>);          // true
static_assert(std::is_floating_point_v<double>); // true
static_assert(std::is_pointer_v<int*>);          // true
static_assert(std::is_class_v<std::string>);     // true
static_assert(std::is_same_v<int, int32_t>);     // true (platform-dependent)

// Type transformations (result in a type, not bool):
using T1 = std::remove_const_t<const int>;   // int
using T2 = std::remove_pointer_t<int*>;       // int
using T3 = std::decay_t<const int&>;          // int (removes const, ref, arrays->ptr)
using T4 = std::add_lvalue_reference_t<int>;  // int&

// std::integral_constant: compile-time integral value as a type
using one     = std::integral_constant<int, 1>;
using two     = std::integral_constant<int, 2>;
using three   = std::integral_constant<int, one::value + two::value>;
static_assert(three::value == 3);

// Custom type trait:
template<typename T>
struct is_smart_pointer : std::false_type {};

template<typename T>
struct is_smart_pointer<std::unique_ptr<T>> : std::true_type {};

template<typename T>
struct is_smart_pointer<std::shared_ptr<T>> : std::true_type {};

template<typename T>
constexpr bool is_smart_pointer_v = is_smart_pointer<T>::value;

static_assert(is_smart_pointer_v<std::unique_ptr<int>>); // true
static_assert(!is_smart_pointer_v<int*>);                 // false - raw ptr not smart

static_assert: Compile-Time Validation

cpp
// Assert type sizes for ABI compatibility:
static_assert(sizeof(int) == 4, "Expected 32-bit int");
static_assert(sizeof(void*) == 8, "64-bit pointers required");
static_assert(sizeof(float) == 4, "IEEE 754 float required");

// Assert struct layout (no unexpected padding):
struct PacketHeader {
    uint32_t magic;
    uint16_t version;
    uint16_t flags;
    uint32_t length;
};
static_assert(sizeof(PacketHeader) == 12, "PacketHeader must be exactly 12 bytes");
static_assert(offsetof(PacketHeader, length) == 8, "length must be at byte offset 8");

// With template parameters:
template<typename T>
class NumericBuffer {
    static_assert(std::is_arithmetic_v<T>, "T must be arithmetic type");
    static_assert(!std::is_same_v<T, bool>, "bool is not valid for numeric buffer");
    std::vector<T> data_;
};

// Use in constexpr context:
template<size_t N>
struct FixedString {
    static_assert(N > 0 && N <= 256, "FixedString length must be 1-256");
    char data[N];
};

Frequently Asked Questions

Does constexpr always run at compile time? No - constexpr means "allowed at compile time if called with constant expressions." If called with runtime values, it executes at runtime normally. Use consteval when you want to guarantee compile-time execution and make runtime calls a hard error. Use if (std::is_constant_evaluated()) to write functions that behave differently in each context.

Why would constexpr slow down my build? Compile-time computation uses the compiler as an interpreter. Complex constexpr computations - sorting large arrays, generating CRC tables - move computation to build time. This increases build time but produces a faster binary. For very complex constexpr (400+ line recursive templates), builds can become noticeably slower. Profile build time with -ftime-report (GCC) or clang -ftime-trace.

What is the "static initialization order fiasco" and how does constinit help? In C++, the initialization order of global variables across translation units (.cpp files) is undefined. If global_b (in file2.cpp) depends on global_a (in file1.cpp), global_a might not be initialized yet when global_b's initializer runs. constinit prevents this by requiring the initializer to be a constant expression (evaluated at compile time) - no runtime initialization = no initialization order dependency.


Key Takeaway

Compile-time programming in C++ is not just a performance trick - it's a correctness mechanism. Compile-time validated format strings catch bugs before the program ships. Compile-time lookup tables eliminate an entire category of runtime computation. static_assert enforces struct layout contracts across platforms. consteval makes misuse a compile error rather than a runtime crash. Every time you push work left - from runtime to compile time - you get a faster binary and a safer system.

Read next: Type Traits & static_assert: Defensive Programming ->


Part of the C++ Mastery Course - 30 modules from modern C++ basics to expert systems engineering.