Const, constexpr, consteval, and constinit in Modern C++

How to choose the right const-related C++ keyword based on the guarantee you need, with embedded-focused notes on API mutation, compile-time evaluation, startup initialization, and toolchain support.

C++ has several keywords that look related because they contain const, but they solve different engineering problems. That difference matters in real code reviews: a value may be read-only through one reference while another owner can still change it, a helper may be available for compile-time use but still run at runtime, and a global may be initialized early while remaining mutable for the rest of the program.

When those guarantees are blurred, the code often looks cleaner than it really is. The practical way to choose between const, constexpr, consteval, and constinit is to start with the failure you want the compiler to catch, then pick the keyword that gives that guarantee without adding noise.

Contents
  1. Start with the guarantee, not the spelling
  2. const prevents mutation through this access path
  3. Const member functions are API promises
  4. constexpr makes compile-time use possible
  5. consteval forces compile-time evaluation
  6. constinit controls static initialization
  7. Useful combinations and noisy combinations
  8. Embedded fit and toolchain checks
  9. Selection table
  10. Practical takeaway

Start with the guarantee, not the spelling

The useful question is not which keyword looks most modern, but what failure you are trying to prevent. In a firmware review, the following problems may all look like “constant” problems at first, but they point to different fixes:

  • A driver accidentally rewrites caller configuration.
  • A buffer size calculation silently changes after a constant is edited.
  • A protocol identifier is built from runtime data even though it should be fixed by the protocol.
  • A global depends on dynamic initialization before the startup path is ready.

These are four different risks, even though the keywords look related at first glance. They overlap in spelling more than they overlap in purpose, which is why choosing by habit tends to produce code that is either too weak or unnecessarily strict.

const prevents mutation through this access path

const is mainly a local contract about mutation. It says an object cannot be modified through this name, pointer, or reference, but it does not automatically mean the value was computed at compile time, and it does not always mean the underlying object can never change.

That contract is useful in driver configuration, parser inputs, sample buffers, and any API where the callee should read data without changing the caller’s state. It also makes reviews easier, because the function signature tells you what the function is allowed to do before you read the implementation.

The phrase “through this access path” is the detail worth keeping. A const reference does not freeze every possible reference to the same object; if another non-const name still exists, that name can still modify the object.

That distinction is not language trivia. It matters when a function stores references, when multiple modules share state, or when a const reference is only a view into data owned elsewhere. const protects one access path, but it is not an ownership model.

Const member functions are API promises

Member functions use const as a promise that the call does not modify the observable state of the object. That is especially useful for small value types, register descriptions, decoded protocol fields, and measurement objects where readers expect inspection methods to be harmless.

There are legitimate uses of mutable, such as caching a derived value or protecting a lock object, but it should stay rare in small embedded types. If many members need to be mutable, the type is probably carrying more responsibility than its name suggests.

Use const when the rule is about write access. It is the right tool for API clarity, not a shortcut for compile-time constants.

constexpr makes compile-time use possible

constexpr is about constant expressions. A constexpr variable must be initialized with a value the compiler can compute, and a constexpr function can run at compile time when its inputs are known at compile time.

That is useful for fixed buffer sizes, register masks, protocol constants, table dimensions, and small calculations where an early compiler check is cheaper than a late debugging session.

A common mistake is assuming every constexpr function call happens at compile time. It does not. If the arguments are runtime values, the same function can execute at runtime.

That dual behavior is often exactly what you want, because one small helper can serve compile-time checks and normal runtime code. If runtime use would be a design error, though, constexpr is too permissive, and that is where consteval becomes useful.

consteval forces compile-time evaluation

consteval means the function must produce its result during compilation. If you call it with runtime data, the compiler rejects the program. The language reference calls this kind of function an immediate function, which is a good mental model because there is no runtime fallback path.

This is useful when runtime use would hide a design mistake: command identifiers built from literal fields, fixed register masks, compile-time pin validation, or any helper where the value must be known before the program runs.

consteval can also turn configuration mistakes into build errors. For embedded work, that is useful when a value belongs to the board design or protocol definition rather than to a runtime setting.

Use consteval when the compiler should refuse runtime use. Do not use it only because it looks stricter; if a helper is useful in both compile-time and runtime paths, constexpr is usually the more practical choice.

constinit controls static initialization

constinit is about startup initialization. It checks that a variable with static or thread-local storage duration is initialized during static initialization, but it does not make the variable read-only.

This matters in systems where startup order is part of reliability. Global objects across translation units can run into dynamic initialization order problems, and firmware startup is already complicated enough without a global quietly depending on runtime work before main() or before the scheduler starts.

The compiler rejects an initializer that needs runtime work:

For firmware-style code, constinit is reasonable for simple global state that must have a known early value, such as counters, flags, pointers initialized to nullptr, and small compile-time defaults.

Use constinit when initialization timing is the problem you are solving. If the problem is mutation, use const; if the problem is compile-time use, use constexpr; and if runtime use must be impossible, use consteval.

Useful combinations and noisy combinations

Some combinations express a real design choice, while others only make the declaration look more deliberate than it really is.

A constexpr variable is already constant, so const constexpr is redundant in normal code.

constinit const can be useful for a static object that must be initialized early and must not change later. If the value also needs to participate in static_assert, template arguments, or array bounds, constexpr is normally clearer. One language detail worth remembering is that constinit and constexpr cannot be applied to the same declaration.

For member functions, constexpr and trailing const solve different problems, so it is common for them to appear together.

Here constexpr says mask() can participate in compile-time evaluation, while the trailing const says the call does not mutate the RegisterField object. Those are separate guarantees, and both are useful.

Embedded fit and toolchain checks

For small embedded systems, const and constexpr are usually easy to justify because they improve clarity without adding runtime machinery. consteval and constinit are C++20 features, so support depends on the exact compiler, standard library, and selected language mode. Larger RTOS systems, ESP-IDF-class projects, embedded Linux, and host-side tools generally have more room to use modern C++ features, but the compiler mode still has to be real.

Before standardizing on C++20 keywords across a firmware codebase, check the toolchain and project constraints that decide whether the guarantees are actually available:

  • The exact compiler version and C++ mode, such as -std=c++20.
  • Vendor documentation for partial or nonconforming support.
  • Whether the same code must build in host tests and target firmware.
  • Whether startup code, linker scripts, and static storage rules are documented for the project.
  • Whether the code is shared with older branches or customer SDKs that still use C++17.

Useful references:

Selection table

Keyword Main guarantee Good use case Common mistake
const This access path cannot modify the object Read-only parameters, views, member functions Treating it as a compile-time guarantee
constexpr Can participate in constant expressions Buffer sizes, masks, small reusable calculations Assuming every call runs at compile time
consteval Must run at compile time Build-time IDs, board constants, validation helpers Using it for values that may be runtime settings
constinit Static or thread-local object is initialized early Global flags, counters, simple startup state Assuming it means immutable

Practical takeaway

These keywords are easier to use when you name the failure you want to prevent. Use const to prevent accidental writes through an API, constexpr when compile-time use is valuable but runtime use is still valid, consteval when runtime use would hide a design error, and constinit when startup initialization order is the risk.

That is the practical distinction. Good modern C++ does not use the strongest-looking keyword everywhere; it uses the keyword that gives the right guarantee with the least confusion for the next engineer reading the code.

Saeid Yazdani working at an electronics workbench
Saeid Yazdani

Embedded Systems Engineer with 20+ years of professional experience developing firmware, electronics, measurement systems, and hardware-software solutions. I have been programming for more than two decades and write about Embedded C/C++, STM32, AURIX, PCB design, debugging, and practical engineering lessons from real-world projects.

Articles: 40

Leave a Reply

Your email address will not be published. Required fields are marked *