C++ Constant Initialization: constexpr, constinit, and the Static Initialization Order Problem

Key takeaways

C++ constant initialization determines whether a static or global variable's value is fixed at compile time or computed at runtime. This guide covers constexpr, constinit, the static initialization order fiasco, and how to sidestep it.

What is Constant Initialization?

Constant initialization refers to initialization where the value is determined at compile time. It is often used alongside constexpr functions, constexpr if, and is distinct from value initialization and zero initialization.

constexpr int x = 10;  // Constant initialization
int y = compute();     // Dynamic initialization

The reason this category exists as its own term — rather than just being “constexpr” — is that the standard needs to describe initialization that happens before your program’s main() even starts running, for globals and statics that live outside any function. Constant initialization and zero initialization together make up “static initialization,” which the compiler can fully resolve while translating the source file. Dynamic initialization, by contrast, has to run actual code at program startup, which is where the ordering problems below come from. Knowing which bucket a given global falls into is the difference between a program that behaves the same on every platform and one that occasionally crashes on startup depending on link order — a distinction that’s invisible until it isn’t.

constexpr Variables

constexpr int MAX = 100;
constexpr double PI = 3.14159;
constexpr const char* MSG = "Hello";

// Compile-time computation
constexpr int square(int x) {
    return x * x;
}

constexpr int result = square(5);  // 25

A constexpr variable makes two promises at once: the compiler can compute it at compile time, and it is const — you can never reassign it later. Those two things happen to travel together for constexpr, but they aren’t the same requirement, which is exactly the gap constinit fills further down. Also worth internalizing: constexpr on a variable is a hard requirement (the initializer must be a compile-time constant expression, full stop, or the build fails), whereas constexpr on a function is more like a permission slip — the function can run at compile time if all its inputs are constant expressions, but the same function is equally happy to run at runtime with non-constant arguments. square(5) above is compile-time; square(readFromStdin()) would just be a normal runtime call to the same function.

Practical Examples

Example 1: Array Size

constexpr size_t SIZE = 10;
int arr[SIZE];  // OK: Compile-time constant

void func(int n) {
    // int arr2[n];  // Error: VLA (non-standard in C++)
}

This is the example people run into most often without realizing it’s about constant initialization at all — std::array<int, SIZE> and fixed-size C arrays both need their size to be a compile-time constant, because the compiler has to know how much stack space to reserve at the point the array is declared. GCC and Clang both accept variable-length arrays (arr2 above) as a non-standard extension, which is exactly why code that “works” in a quick test on one compiler can fail to build elsewhere — MSVC rejects VLAs outright. If a size genuinely isn’t known until runtime, that’s a std::vector, not an array.

Example 2: Static Variables

struct Config {
    int width;
    int height;
};

constexpr Config defaultConfig = {800, 600};

int main() {
    Config cfg = defaultConfig;
}

Aggregates like Config get constant-initialized member by member, as long as every member’s initializer is itself a constant expression — no runtime logic hides in this the way it can with a constructor. That’s a useful property for configuration structs specifically: a constexpr Config is guaranteed to exist fully formed before any other code runs, with no dependency on initialization order relative to other translation units.

Example 3: constinit (C++20)

// constinit: Forces constant initialization
constinit int x = 10;  // OK: Compile-time

// constinit int y = compute();  // Error: Runtime

// constexpr vs constinit
constexpr int a = 10;  // Compile-time constant
constinit int b = 10;  // Constant initialization, but can be modified at runtime

int main() {
    // a = 20;  // Error: constexpr
    b = 20;  // OK: constinit
}

constinit exists specifically for the case constexpr can’t cover: a global or static variable that needs to be mutable at runtime but must never fall back to dynamic (order-dependent) initialization. A logging subsystem’s global severity level is a realistic example — you want it initialized to a known value before any static constructor elsewhere could possibly touch it, but you also need to change it later when the program parses --verbose. Before constinit, the only way to enforce “this had better be constant-initialized” was to inspect the generated assembly or just hope; now it’s a compile error if you get it wrong, which turns a class of startup-order bugs into build failures instead of runtime mysteries.

Example 4: Static Initialization Order

// file1.cpp
constexpr int x = 10;
constexpr int y = x + 1;  // OK: Order guaranteed

// file2.cpp
int a = 10;
int b = a + 1;  // Order not guaranteed (dynamic initialization)

This is the “static initialization order fiasco” the FAQ mentions, and it’s one of the few C++ bugs I’d call genuinely nasty to diagnose — it depends on link order, which can differ between a debug build and a release build, or even between two runs on different machines if you’re linking against a shared library. I once spent an afternoon chasing a crash that only happened in the optimized build, which turned out to be a global std::string in one translation unit being read by a static initializer in another translation unit, before the string’s own constructor had run. The value looked plausible (empty-ish garbage) right up until it wasn’t. constexpr/constinit sidesteps the entire class of bug because compile-time-initialized values simply exist before any translation-unit ordering question can even arise — there’s no “before main()” race to lose.

Initialization Order

// 1. Zero initialization
static int x;  // 0

// 2. Constant initialization
constexpr int y = 10;

// 3. Dynamic initialization
int z = compute();

This ordering is fixed by the standard and always runs in this sequence, regardless of how the variable is declared: every static-storage-duration object is zero-initialized first (giving it a well-defined bit pattern even before any explicit initializer runs), then constant-initialized where possible, and only then — if neither of the first two apply — does dynamic initialization run. That’s why a static int x; with no initializer is safely 0 from the moment the program starts, not “whatever garbage was in memory,” and why constant initialization is best understood as a compiler optimization of zero-initialized statics rather than a wholly separate mechanism.

Common Issues

Issue 1: Dynamic Initialization

// ❌ Runtime computation
int getValue() { return 42; }
constexpr int x = getValue();  // Error

// ✅ constexpr function
constexpr int getValue() { return 42; }
constexpr int x = getValue();  // OK

The error message for the first version is usually clear enough (“not a constant expression”), but it gets confusing once overload resolution is involved — if getValue has both a constexpr and non-constexpr overload (rare, but it happens with templates), the compiler picks whichever one it needs for the context, and a seemingly unrelated code change elsewhere can silently switch which overload gets called.

Issue 2: Static Initialization Order Problems

// file1.cpp
int x = compute1();

// file2.cpp
extern int x;
int y = x + 1;  // x might not be initialized yet

// ✅ Use constexpr
constexpr int x = 10;
constexpr int y = x + 1;  // Order guaranteed

If the value genuinely can’t be constexpr — it comes from a config file, an environment variable, or another runtime source — the standard workaround is the “construct on first use” idiom: wrap the global in a function returning a static local, so it’s lazily initialized the first time it’s actually needed rather than at some unspecified point during startup. That sidesteps the cross-TU ordering problem by making the order of first use the order of initialization, which your own code controls.

Issue 3: constinit Restrictions

// ❌ Local variables
void func() {
    // constinit int x = 10;  // Error
}

// ✅ Only for static variables
constinit static int x = 10;
constinit int global = 10;

constinit is restricted to variables with static or thread storage duration for the same reason it exists in the first place — a local variable is initialized every time control flow reaches its declaration, which is inherently a runtime event, so “constant-initialize this local” isn’t a meaningful thing to ask for. If you want a local constant, constexpr (or just plain const) already does the job.

Issue 4: Pointers

// ❌ Runtime address
int x = 10;
constexpr int* ptr = &x;  // Error

// ✅ constexpr variable
constexpr int y = 10;
constexpr const int* ptr = &y;  // OK (C++20)

This restriction loosened in C++20 specifically to allow constexpr pointers to objects with static storage duration, because the address of a static-storage variable is itself known at compile/link time — it’s not a “runtime address” in the sense that matters here, even though the literal bit pattern of the address isn’t known until the linker runs. Before C++20, this forced awkward workarounds (indices into arrays instead of pointers) in code that wanted compile-time pointer constants; it’s a niche fix, but it matters for constexpr-heavy metaprogramming and embedded code that builds lookup tables of addresses at compile time.

Performance Benefits

// Dynamic initialization: runtime cost
int x = expensiveComputation();

// Constant initialization: no cost
constexpr int x = 42;

“No cost” is accurate but easy to overstate — the saving isn’t that constexpr makes the computation faster, it’s that the computation doesn’t happen at runtime at all; the result is baked into the binary as a literal, the same as if you’d typed 42 directly. For a single integer this is a rounding error either way. It starts to matter when the constant is something expensive to derive — a lookup table computed by a loop, a hash of a fixed string, a compile-time-parsed configuration — where doing that work once at compile time instead of once per program startup (or worse, repeatedly) is a real, measurable difference, particularly for code that starts up frequently, like a CLI tool invoked in a build pipeline.

FAQ

Q1: What is constant initialization?

A: Initialization at compile time.

Q2: constexpr vs constinit?

A:

  • constexpr: Compile-time constant.
  • constinit: Forces constant initialization but allows runtime modification.

Q3: Performance?

A: No runtime cost.

Q4: Initialization order?

A: Zero -> Constant -> Dynamic.

Q5: When to use?

A:

  • For compile-time constants.
  • To prevent static initialization order issues.

Q6: Resources for learning constant initialization?

A:

  • “Effective Modern C++”
  • “C++20 The Complete Guide”
  • cppreference.com