C++ const, constexpr and consteval: Runtime Immutability vs Compile-Time Guarantees
See exactly what each C++ keyword guarantees. One C++20 program separates immutability, optional constant evaluation and mandatory constant evaluation.
KnowledgeGate Team
Exam prep & CS education

const, constexpr and consteval all look like different ways to say "constant", but they make different promises. One blocks mutation through an object, one makes a value or function available to constant-expression contexts, and one rejects ordinary runtime calls. A C++20 program demonstrates the distinction: it outputs exactly 6 8 14 81, and two deliberately invalid lines reveal the boundaries. The broader Coding & Skill Development category connects this topic to related programming courses and tutorials.
Related reading: C++ overload resolution and C++ iterator rules.
Three keywords, three different contracts
Think in contracts before thinking in syntax. const asks, "May this object be modified through this name?" A constexpr object must have a constant-expression initializer. A constexpr function can be evaluated at compile time when its arguments and the surrounding context qualify, but it can also serve a runtime caller. A consteval function is immediate, so each ordinary call must succeed as a constant expression. A constexpr object is also const, but an arbitrary const object need not be a constant expression.
In our program, runtime_limit becomes 6 without a compile-time guarantee. double_it(4) supplies the compile-time array size 8, while double_it(user_value) produces 14 in an allowed runtime call when user_value is 7. square(9) produces 81, but square(user_value) is rejected. WG21's P1073R3: Immediate functions defines the immediate-function model and explains why constexpr does not force every call to be evaluated during translation. If these declarations are new, the C++ Tutorial gives a wider learning path.

const: immutable after initialisation does not mean compile-time
Start with an ordinary function and a const object:
int load_limit() { return 6; }
const int runtime_limit = load_limit();The observed value is 6, and runtime_limit = 9; is rejected because the object cannot be assigned after initialisation. However, static_assert(runtime_limit == 6); is ill-formed. The initializer calls a non-constexpr function, so runtime_limit is not usable as a constant expression. The missing property is constant-expression eligibility, not immutability.
Do not turn that into the rule "const is always runtime". An integral declaration such as const int literal_limit = 6; has a constant-expression initializer and can be usable in a constant-expression context. The accurate rule is that const alone does not guarantee compile-time evaluation.
Pointer declarations show why you must identify what is const. With int a = 5, b = 8;, const int* p = &a; permits p = &b; but rejects *p = 9;: the pointed-to integer is protected through p. By contrast, int* const q = &a; permits *q = 9; but rejects q = &b;: the pointer itself cannot be redirected.
constexpr: the call is compile-time only when the context requires it
Consider one function:
constexpr int double_it(int x) { return 2 * x; }Now use it in three contexts that require constant expressions. constexpr int table_size = double_it(4); makes table_size exactly 8. static_assert(double_it(5) == 10); must be checked during translation. Finally, std::array<int, double_it(4)> table{}; has exactly 8 elements because its non-type template argument must be a constant expression.
The same function remains valid here:
int user_value = 7;
int run_result = double_it(user_value);This call produces 14 at runtime. It is not a constant expression because it reads a non-const runtime object, but a constexpr function is allowed to serve that caller. A compiler may still optimise the multiplication; such folding is not a language guarantee supplied by constexpr.
The distinction is between object and function contracts. A constexpr variable is const and must be initialised by a constant expression. A constexpr function can support both compile-time and runtime calls. This section's code is valid in C++17. For adjacent class and object concepts, see Object Oriented Technology Explained, not as evidence for constant-expression rules but as the next conceptual layer in C++.
consteval: C++20 turns failed constant evaluation into a compile error
C++20 adds an immediate function declaration:
consteval int square(int x) { return x * x; }constexpr int token = square(9); is valid and fixes token at 81. Even int also_ok = square(10); is valid. The destination is not constexpr, but the call itself has the constant argument 10, so square(10) must be evaluated as a constant expression and yields 100.
The boundary appears with int user_value = 7; int rejected = square(user_value);. This is ill-formed because an immediate call cannot read that non-constant object. An optimiser noticing that it currently contains 7 does not change the language rule.
The version boundary is equally clear. consteval is absent from C++17 and part of C++20. Compile the full example as C++20. For a C++17 teaching variant, remove square, token, and the fourth printed value; the const and constexpr demonstrations remain.
Worked example: compile one file, then expose both rejected lines
Use this exact positive C++20 program:
#include <array>
#include <iostream>
int load_limit() { return 6; }
constexpr int double_it(int x) { return 2 * x; }
consteval int square(int x) { return x * x; }
int main() {
const int runtime_limit = load_limit();
constexpr int table_size = double_it(4);
std::array<int, table_size> table{};
int user_value = 7;
int run_result = double_it(user_value);
constexpr int token = square(9);
static_assert(double_it(5) == 10);
std::cout << runtime_limit << ' '
<< table.size() << ' '
<< run_result << ' '
<< token << '\n';
}The output is:
6 8 14 81Trace each number instead of memorising the line. load_limit() returns 6, which initialises an immutable object through an ordinary function. double_it(4) computes 2 * 4 = 8; that result becomes both table_size and the std::array size. The runtime call computes double_it(7) = 2 * 7 = 14. The immediate call computes square(9) = 9 * 9 = 81. The array declaration supplies a concrete check: without a constant-expression table_size, its template argument would be rejected. The positive file compiles cleanly as C++20. That confirms the language uses without making any claim about optimiser behaviour.
Next add this line inside main, compile, then remove it:
int rejected = square(user_value);It must fail because user_value is not a constant expression. Then test the second line separately:
static_assert(runtime_limit == 6);It must fail because the call to load_limit() was not a constant expression. Testing one rejected line at a time keeps each diagnostic tied to one rule. The C++17 variant omits the consteval declaration and use, compiles the remaining program as C++17, and prints 6 8 14.

Common traps and how checks expose them
Trap | Correction from the program |
|---|---|
Every const value is compile-time |
|
Every |
|
Optimiser folding proves a language guarantee | Optimisation does not turn a non-constant expression into a guaranteed constant expression. |
| The |
constinit concerns static initialisation. It is neither general immutability nor a rule requiring every later call to be constant-evaluated.
Use these checks to test your explanation:
Why does
std::array<int, table_size>compile with size8?Why may
double_it(user_value)return14?Why does
square(user_value)fail whenuser_valuecurrently stores7?Why is
static_assert(runtime_limit == 6)invalid although the program later prints6?
Each answer should name the constant-expression context and the relevant declaration contract, not claim that "the compiler is smart enough". Also test the version: the unmodified file is valid under C++20, while C++17 rejects the consteval keyword.
The short version and the next step
Use
constwhen mutation must be blocked, as withruntime_limit = 6.Use
constexprwhen a value or reusable function should be available to constant-expression contexts. That coverstable_size = 8while still allowingrun_result = 14.Use
constevalin C++20 when an ordinary direct call must be rejected unless it can be evaluated as a constant expression, as withtoken = 81.
Now change user_value from 7 to 11. Predict double_it(11) = 22, then explain why square(user_value) remains invalid. Continue with the C++ Programming Course for the direct structured route, or use Coding for Placements for a broader multi-language route.
Keep learning

C++ Object Lifetime and Storage Duration: Trace Construction, Destruction, and Dangling State
Learn to separate scope, storage duration, lifetime, and ownership. Trace seven objects precisely, then repair dangling pointers, references, and views.

C++ Diamond Problem: Ambiguity, Duplicate State and Virtual Inheritance
See why a C++ diamond duplicates base state, how virtual inheritance shares it, and when qualification, interfaces, or composition is the clearer design.

C++ Abstract Classes and Interfaces: Build a Usable Storage Contract
Build a C++17 ScoreStore interface, swap vector and hash-backed implementations behind it, and verify both with the same runnable contract test.

C++ Virtual Functions and Polymorphism: Trace Dynamic Dispatch Step by Step
Trace a Shape hierarchy line by line to see how static type, dynamic type, final overriders, object slicing, and virtual destructors determine C++ output.