C++ Dynamic Initialization: When Globals and Statics Run Their Initializers

Key takeaways

A static-storage variable whose initializer is not a constant expression is initialized at runtime. This covers when that happens, what order is guaranteed, and how it fails: exceptions before main, order bugs, and recursive local statics.

What counts as dynamic initialization

Variables with static storage duration (namespace-scope globals, static class members, function-local statics) and thread storage duration (thread_local) are initialized in one of two ways:

  • Static initialization happens before any code runs. It covers zero-initialization, which applies to everything first, and constant initialization, when the initializer is a constant expression. The values are typically written into the executable’s data section, so no runtime work is done.
  • Dynamic initialization covers everything else. The initializer must be evaluated at runtime because it calls a non-constexpr function, reads a runtime value, or calls a non-constexpr constructor.
int getValue() { return 42; }
constexpr int answer() { return 42; }

int x = getValue();      // dynamic: getValue is not constexpr
int y = answer();        // constant initialization: constant expression
const int z = 10 * 4;    // constant initialization
std::string s = "hi";    // dynamic in C++17 (std::string ctor not constexpr until C++20)

This is decided by the initializer, not by the keyword on the variable. int y = answer(); is not const, but it is still constant-initialized because the expression is a constant expression. int x = getValue(); returns the same 42 but is dynamic in the language’s terms. The standard does let the compiler turn a dynamic initialization into a static one if the result is the same, and optimizers often do this for simple cases. You cannot rely on it, though, and that is the reason C++20 added constinit.

When dynamic initializers run

Namespace-scope and static member variables

Dynamic initializers of globals run before main in practice. Formally, an implementation may defer them until the first use of something in the same translation unit, but mainstream toolchains run them during startup. The order rules are:

  1. Within one translation unit, variables are initialized in the order they are defined. (Class template static members and variable templates are an exception: their order is unspecified.)
  2. Across translation units, the order is unspecified. The linker’s input order usually decides it, which means reordering files in a CMakeLists.txt can change behavior.
  3. Before any dynamic initializer runs, every static-storage object has already been zero-initialized.

Rule 3 is why cross-file order bugs are so confusing. A global std::vector from another file that is not constructed yet is not garbage. It is all zero bytes, which in common implementations happens to look like a valid empty vector. A read returns “empty”, an insert writes into an object whose constructor then runs and wipes it, and the program continues with missing data instead of crashing. The crash, if it comes, arrives later and somewhere else. The dedicated article on the initialization order fiasco and its fixes covers this in depth.

Destruction runs in the reverse order of completed construction, after main returns or exit is called. That creates the same problem in reverse: a destructor that uses another file’s global may run after that global has been destroyed.

Function-local statics

void func() {
    static int local = []() {
        std::cout << "Static local variable initialization\n";
        return 200;
    }();
}

int main() {
    std::cout << "main started\n";
    func();   // prints the init message
    func();   // no output
}

A block-scope static with a dynamic initializer is initialized the first time control passes through its declaration, not at startup. The compiler implements this with a hidden guard variable that is checked on every call. After the first call the check is a single, well-predicted branch, so the cost is small but not zero. That cost is sometimes measurable in extremely hot code.

If the initializer throws, the variable is not considered initialized, and the next call tries again:

int attempts = 0;
int flaky() { if (++attempts < 3) throw std::runtime_error("fail"); return 7; }
int get() { static int v = flaky(); return v; }

// Calling get() three times, catching exceptions:
// threw, attempts=1
// threw, attempts=2
// 7

This is useful behavior (a failed lazy load can be retried), but it surprises people who assumed “static means runs once”.

Thread safety of local statics

Since C++11, if several threads reach the declaration at the same time, exactly one runs the initializer and the others block until it finishes. GCC and Clang implement this with __cxa_guard_acquire / __cxa_guard_release around the initializer. You can turn it off with -fno-threadsafe-statics, and some embedded and game codebases do to save the guard overhead. If you inherit such build flags, the Meyers singleton pattern is no longer safe.

The one case the guarantee does not cover is recursion: if the initializer of a static directly or indirectly calls the same function again, the behavior is undefined. In practice it either deadlocks, because the thread waits on a guard it holds itself, or aborts. My test with MinGW GCC 10 simply hung, and on Linux libstdc++ can report it with a __gnu_cxx::recursive_init_error exception. A hang with no CPU usage during startup is worth checking for this.

Failures before main

Because global initializers run before main, a try block in main cannot catch their exceptions:

int load() { throw std::runtime_error("config file missing"); }
int port = load();

int main() { std::cout << "main\n"; }   // never reached
terminate called after throwing an instance of 'std::runtime_error'
  what():  config file missing

The program calls std::terminate, and nothing in main gets a chance to log or clean up. I have seen this pattern where a global object reads a config file or environment variable in its constructor. It works in development and aborts at startup in a container where the file is missing, and the output does not say which global threw. When debugging a crash before main, set a breakpoint on the throw (catch throw in gdb) and look at the backtrace. The initializer frames show up as _GLOBAL__sub_I_<file> functions, which name the translation unit.

The fix is to keep throwing and I/O work out of global initializers entirely. Construct such objects inside main (or in a function-local static accessed from main), where exceptions can be caught and reported.

Forcing static initialization: constinit (C++20)

int compute() { return 42; }
constinit int a = compute();
error: 'constinit' variable 'a' does not have a constant initializer
error: call to non-'constexpr' function 'int compute()'

constinit asserts that a static-storage variable is constant-initialized, and it does not make the variable const. The variable can still be modified at runtime, but it is guaranteed to have its value before any dynamic initializer runs. That removes it from the order problem entirely. It is the right tool for globals that other files’ initializers depend on, such as a registry pointer or a counter. It also catches the case where a constructor quietly stops being constexpr after a refactor.

constexpr gives the same guarantee but also makes the variable const. Use constexpr for true constants, and constinit for mutable globals that must be ready early.

thread_local

thread_local variables with dynamic initializers are initialized once per thread, either at thread start or lazily on first use in that thread, depending on the implementation and where they are declared. Access to a namespace-scope thread_local with a dynamic initializer from another translation unit goes through a wrapper function that checks initialization, which is slower than accessing a constant-initialized one. If the initializer can be a constant expression, constinit thread_local removes that wrapper.

Choosing an approach

NeedUse
A fixed value known at compile timeconstexpr
A mutable global that must be ready before other initializersconstinit
An object that needs runtime data or can failConstruct in main, pass it down
A lazily created shared objectFunction-local static (keep thread-safe statics on)
A global depending on a global in another fileNever read it directly; use an accessor function

Plain dynamically initialized globals are fine when they depend on nothing else and cannot fail, for example a std::string built from a literal or a lookup table built by a local function. Problems start with dependencies across files and with work that can throw.