C++ Zero Initialization: When Objects Start at Zero and When They Don't

Key takeaways

Zero initialization gives every scalar the value zero converted to its type. Objects with static or thread storage get it before anything else runs; automatic and heap objects only get it through value initialization such as T{}, and never when a class has a user-provided constructor.

What zero initialization is

Zero initialization is one of the forms of initialization the C++ standard defines, and it’s usually a step inside another form rather than something you write directly. Applied to an object, it means:

  • a scalar (integer, floating point, bool, enum, pointer) gets the value 0 converted to its type: 0, 0.0, false, the null pointer value;
  • each non-static member and base class of a class is zero-initialized, and padding is set to zero bits;
  • each element of an array is zero-initialized;
  • a union’s first non-static member is zero-initialized;
  • references aren’t initialized at all (they must be bound by other means).

Note the wording: “the value zero converted to the type”, not “all bytes zero”. For int and IEEE floating point those are the same thing, which is why it’s often described as “all bits zero”. For pointers the standard only promises the null pointer value, and there is one mainstream case where that differs from zero bytes, covered below.

Who gets it automatically

Two kinds of objects are zero-initialized before anything else happens to them:

  • objects with static storage duration: namespace-scope variables, static data members, static local variables;
  • objects with thread storage duration: thread_local variables, once per thread.
int global;                  // 0
static int fileLocal;        // 0

struct MyClass { static int count; };
int MyClass::count;          // 0

void func() {
    static int counter;      // 0, from program start, not "on first call"
    thread_local int perThread; // 0 in every thread
    int y;                   // indeterminate
    int z{};                 // 0: value-initialized
}

The static int counter comment corrects a common misconception. Function-local statics are zero-initialized with the other static objects at program start; only their dynamic initialization, if they have one (static Logger log{"app"};), waits for the first time control passes through the declaration.

Automatic objects (locals) and dynamic objects (new int) are not zero-initialized unless the initializer asks for it. int y; performs default initialization, which for a scalar does nothing: y has an indeterminate value, and reading it is undefined behaviour. Many people’s intuition comes from debug builds, where the stack often happens to contain zeros on first use. Release builds don’t keep that promise, because nobody made it.

Why globals are free and locals aren’t

The asymmetry has a practical reason. A static object exists for the whole program, so its zero value can be baked into the executable’s image. Toolchains put zero-initialized statics in a section conventionally called .bss that occupies no space in the file; the loader maps zero-filled pages for it. You can see the difference directly. With MinGW GCC 10.3 at -O2:

int big[1000000];        // bss0.cpp: executable about 200 KB
int big[1000000] = {1};  // bss1.cpp: executable about 4.2 MB

The first array costs nothing in the file and nothing at startup (the OS supplies zero pages lazily). The second has one non-zero element, so the whole 4 MB array must be stored as initialized data.

A local has to be created every time its scope is entered, possibly millions of times, and zeroing it each time would cost real work. C++ follows C in not paying that cost unless asked. The corollary: int largeArray[1000000]{}; inside a function is both a 4 MB zero-fill on every call and larger than the default main-thread stack on Windows (1 MB), so it will overflow the stack before the zeroing matters. Big buffers belong in a std::vector or in static storage.

Getting zero initialization for locals: value initialization

For locals and heap objects, you get zero initialization through value initialization, which is what empty parentheses or braces request:

int a{};             // 0
int* p = new int();  // *p == 0
double d = double(); // 0.0
std::array<int, 4> arr{};  // all zeros

For class types the rule has a twist that catches experienced programmers. Value initialization zero-initializes the object first only if the default constructor is not user-provided:

struct Plain   { int a; };                   // implicit constructor
struct Def     { int a; Def() = default; };  // defaulted on first declaration: not user-provided
struct Custom  { int a; Custom() {} };       // user-provided
struct OutOfLine { int a; OutOfLine(); };
OutOfLine::OutOfLine() = default;            // defaulted *later*: counts as user-provided

Plain p{};      // a == 0
Def d{};        // a == 0
Custom c{};     // a is indeterminate: the constructor runs, nothing zeroes a
OutOfLine o{};  // a is indeterminate, for the same reason

Custom and OutOfLine tell the compiler “I’m handling construction”, so {} just calls the constructor. The OutOfLine case is the surprising one; defaulting the constructor in the .cpp file changes the initialization semantics of every T{} in the program. The simplest defence is to use default member initializers, int a = 0;, which work regardless of how the constructor is declared.

Aggregates follow their own path: Point p{}; is aggregate initialization, and members without an explicit initializer are copy-initialized from {}, so scalars end up zero. Point p{1}; sets x = 1 and zeroes y. See value initialization and aggregate initialization for the full rules.

The uninitialized accumulator is the version of this I see most: int sum; followed by sum += ... in a loop. It usually produces the right answer in debug builds because the stack slot happens to start at zero, then produces nonsense (or gets optimized in strange ways) in release. -Wall on GCC and Clang flags many of these with -Wuninitialized / -Wmaybe-uninitialized, but not all of them, particularly when the variable is passed by pointer to another function first.

Zero initialization is not memset

It’s tempting to treat memset(&obj, 0, sizeof obj) as a manual zero initialization. It isn’t, in two ways.

First, for non-trivial types it’s simply wrong. A std::string holds a pointer to its buffer, and often a small internal buffer the pointer must point to. Zeroing the bytes doesn’t produce an empty string; it produces an object whose invariants are broken, and its destructor may free a garbage pointer. GCC catches the common cases:

warning: 'void* memset(void*, int, size_t)' clearing an object of type 'struct Widget'
with no trivial copy-assignment; use assignment or value-initialization instead [-Wclass-memaccess]

Second, even for trivial types, zero bytes aren’t always zero values. The standard example is a pointer to data member. On the Itanium C++ ABI used by GCC and Clang on Linux, macOS and MinGW, a pointer-to-member is stored as an offset, offset 0 is a valid member (the first one), so the null value is represented as -1:

struct S { int a; int b; };
struct HasMP { int S::* pm; };

HasMP g;                              // zero-initialized: g.pm == nullptr is true
HasMP m;
std::memset(&m, 0, sizeof m);         // m.pm == nullptr is FALSE: it now points to S::a

Running this with GCC confirms it: the zero-initialized global compares equal to nullptr and the memset one doesn’t. GCC also warns here (“clearing an object of type ‘struct HasMP’ containing a pointer-to-member”). Zero initialization does the right thing because it’s defined in terms of values, not bytes.

Use T{} or obj = T{}; to reset an object. For arrays of trivially copyable scalars, std::fill or memset is fine.

Its role in static initialization order

Static objects are initialized in phases. Static initialization happens first, before any code runs: every static object is either constant-initialized (if its initializer is a constant expression, as with constexpr or constinit) or zero-initialized. Dynamic initialization comes later and runs constructors and non-constant initializers, in unspecified order across translation units.

int g1;                   // static initialization: zero
constexpr int g2 = 50;    // static initialization: constant
int compute() { return 42; }
int g3 = compute();       // zero first, then dynamic initialization sets 42

Zero initialization is why the static initialization order fiasco is so confusing rather than an outright crash:

// a.cpp
int computeValue() { return 42; }
int value1 = computeValue();   // dynamic

// b.cpp
extern int value1;
int value2 = value1 * 2;       // may run before value1's initializer

If b.cpp’s initializers run first, value1 isn’t garbage; it’s already been zero-initialized, so value2 is quietly 0. The program runs, with a wrong value, and whether it’s wrong depends on link order.

The fixes remove the dynamic step. Make value1 a constant, but give it external linkage, because a plain namespace-scope constexpr variable has internal linkage and extern int value1; in another file won’t find it:

// shared header
inline constexpr int value1 = 42;   // C++17: one definition, constant-initialized

Or wrap it in a function-local static, which is initialized on first use (and thread-safely since C++11):

int& getValue1() {
    static int value = computeValue();
    return value;
}

The same reasoning is behind the classic lazy pointer singleton, static Logger* instance; checked for nullptr in getInstance(): it relies on zero initialization making instance null before any code runs. That part is sound, but the check-then-new isn’t thread-safe. A function-local static Logger instance; returned by reference gets both the lazy initialization and the thread safety from the language.

Summary by storage duration

DeclarationStorageResult
int x; at namespace scopestatic0
static int x; in a functionstatic0, before main
thread_local int x;thread0 in each thread
int x; in a functionautomaticindeterminate
int x{}; / int x = int(); anywhereany0 (value initialization)
new int / new int()dynamicindeterminate / 0
T x{}; where T has a user-provided constructoranywhatever the constructor does