C++ Default Initialization: When Variables Stay Indeterminate and How That Bites

Key takeaways

Default initialization happens with no initializer. Local scalars may be indeterminate; reading them is undefined behavior. Differs from globals (zero init first) and from value initialization.

What is default initialization?

Declaring a variable without an initializer applies default initialization. For local scalars, the value can be indeterminate—reading it is undefined behavior. Prefer value initialization (T{}) or explicit assignment.

void func() {
    int x;        // ❌ Default-initialized: indeterminate (garbage)
    int y = 10;   // ✅ Explicit initialization
    int z{};      // ✅ Value-initialized: 0
}

The reason this distinction exists at all comes down to a performance trade-off C made decades ago and C++ inherited: forcing every local variable to be zeroed on entry costs cycles, even in the overwhelming majority of cases where the very next line overwrites that value anyway. Rather than pay that cost unconditionally, the language leaves automatic-storage scalars in whatever bit pattern happened to already be sitting in that stack slot — the “indeterminate value” is not some special sentinel, it’s just leftover data from whatever function call used that stack space before. Class types are different because their constructors have real work to do (invariants to establish), so the compiler always runs that constructor regardless of context; the danger is specifically confined to scalars and to aggregates without user-defined constructors.

Default initialization by context

ContextScalarsClass types
Local variablesIndeterminate ❌Default constructor
Static/globalZero first, then dynamic initZero first, then constructor
new TIndeterminate ❌Default constructor
new T()Zero-initialized ✅Default constructor
Class membersDepends on constructorDefault constructor

Scalars (automatic storage)

The danger zone

What makes this genuinely dangerous rather than merely sloppy is that the failure is non-deterministic in a way that actively hides itself during development. The stack memory backing x, d, and ptr might, on a given run, happen to contain zeros left over from a previous function call — so the program appears to work correctly, sometimes for months, until a different call stack shape, a different compiler optimization level, or a different machine leaves different garbage in that slot. *ptr = 10 is the sharpest example: an indeterminate pointer isn’t necessarily null (which would at least crash predictably) — it’s an arbitrary address, so writing through it can silently corrupt unrelated memory rather than crash at all, which is far harder to diagnose than an immediate segfault.

void dangerous() {
    int x;       // Indeterminate value
    double d;    // Indeterminate value
    int* ptr;    // Indeterminate value
    
    // ❌ All of these are undefined behavior:
    if (x > 0) { }           // UB
    std::cout << d << "\n";  // UB
    *ptr = 10;               // UB (likely crash)
}

The safe way

void safe() {
    int x = 0;           // ✅ Explicit
    double d{};          // ✅ Value-initialized
    int* ptr = nullptr;  // ✅ Explicit
    
    // Now safe to use
    if (x > 0) { }
    std::cout << d << "\n";
    if (ptr != nullptr) {
        *ptr = 10;
    }
}

Uninitialized Accumulators, Pointers, and Flags

Each of the three bugs below shares the same shape: a variable is declared without a value, and there exists at least one code path where it’s read before anything assigns to it. That shape is easy to introduce by accident because the compiler doesn’t require you to prove every path initializes the variable — it only requires the type to be legal, and int sum; is perfectly legal C++ even though the accumulation loop below relies on sum already holding zero.

Uninitialized accumulator

// ❌ Bug: sum is indeterminate
int calculateSum(const std::vector<int>& numbers) {
    int sum;  // Garbage value!
    for (int num : numbers) {
        sum += num;  // UB: using indeterminate value
    }
    return sum;
}
// ✅ Fix
int calculateSum(const std::vector<int>& numbers) {
    int sum = 0;  // Properly initialized
    for (int num : numbers) {
        sum += num;
    }
    return sum;
}

Uninitialized pointer

This variant is worse than the accumulator case because the failure mode compounds: an indeterminate pointer that happens to look like a valid address will let *ptr = 10 succeed in corrupting whatever memory it points at, and the subsequent delete ptr then tries to free memory the allocator never actually gave out — which can crash immediately, corrupt the heap’s internal bookkeeping, or (worst case) appear to work while quietly setting up a crash somewhere else entirely, later, in unrelated code.

// ❌ Bug: ptr points to random memory
void processData() {
    int* ptr;  // Indeterminate!
    
    if (someCondition) {
        ptr = new int(42);
    }
    
    // If someCondition was false, ptr is still indeterminate
    *ptr = 10;  // UB: may crash or corrupt memory
    delete ptr;  // UB: deleting invalid pointer
}
// ✅ Fix
void processData() {
    int* ptr = nullptr;  // Initialized to null
    
    if (someCondition) {
        ptr = new int(42);
    }
    
    if (ptr != nullptr) {
        *ptr = 10;
        delete ptr;
    }
}

Uninitialized flag

Booleans make this bug particularly sneaky because an indeterminate bool isn’t guaranteed to even be 0 or 1 at the bit level — technically it can hold a “trap representation” that isn’t a valid bool value at all, so reading it is undefined behavior in the strictest sense, not just “an unpredictable but valid true/false.” In practice this usually just returns a coin-flip true or false to the caller, but it means processFile can report success for a file it never actually processed, and that false-positive can propagate silently through everything downstream that trusts the return value.

// ❌ Bug: success flag not initialized
bool processFile(const std::string& filename) {
    bool success;  // Indeterminate!
    
    if (fileExists(filename)) {
        success = doProcessing(filename);
    }
    
    // If file doesn't exist, success is indeterminate
    return success;  // UB
}
// ✅ Fix
bool processFile(const std::string& filename) {
    bool success = false;  // Default to failure
    
    if (fileExists(filename)) {
        success = doProcessing(filename);
    }
    
    return success;
}

Class types and default initialization

Class types sidestep the indeterminate-value problem entirely because “default initialization” for a class type means something different than it does for a scalar: it means the compiler must call some constructor, and that constructor’s body is what actually determines the object’s state. This is the real reason class types are the recommended escape hatch from the dangers above — wrapping a scalar-like value in a tiny class with a constructor that assigns a sane default turns an indeterminate-value hazard into a compile-enforced guarantee.

Classes with default constructors

class Widget {
    int value_;
public:
    Widget() : value_(0) {}  // Default constructor
};
void func() {
    Widget w;  // Default-initialized: calls Widget()
    // w.value_ is 0
}

Classes without default constructors

Once a class declares any constructor at all, the compiler stops generating an implicit no-argument constructor — so a type like Point, which only offers Point(int, int), makes Point p; a compile error rather than a silently indeterminate object. This is arguably the single biggest practical advantage class types have over raw scalars for this problem: the compiler can refuse to let you default-construct something that has no sensible default, whereas int x; compiles fine no matter how meaningless the resulting value is.

class Point {
    int x_, y_;
public:
    Point(int x, int y) : x_(x), y_(y) {}
    // No default constructor!
};
void func() {
    // Point p;  // ❌ Error: no default constructor
    Point p(0, 0);  // ✅ Must provide arguments
}

Class members

Uninitialized members

Having a constructor doesn’t automatically save you — Bad() runs successfully and produces a fully “constructed” object by the language’s definition, but value_ was never given a value anywhere in that construction, so it’s just as indeterminate as a bare local int. This is a common trap for people who assume “the constructor ran, so the object must be initialized” — the constructor only initializes what it’s told to initialize, member by member. Default member initializers (C++11’s int value_ = 0; syntax) close this gap structurally: the value lives right next to the declaration, so there’s no separate constructor body to forget to update, and every constructor the class ever gains automatically inherits that default unless it explicitly overrides it in its own initializer list.

class Bad {
    int value_;  // ❌ Not initialized in constructor
    
public:
    Bad() {}  // value_ is indeterminate!
};
// ✅ Fix 1: Member initializer list
class Good1 {
    int value_;
    
public:
    Good1() : value_(0) {}
};
// ✅ Fix 2: Default member initializer (C++11)
class Good2 {
    int value_ = 0;
    
public:
    Good2() = default;
};

Partially initialized objects

This is the multi-member version of the same trap, and it’s more dangerous precisely because it’s partial — x_ is correctly initialized (it’s right there in the initializer list), which makes the class look complete and correct at a glance, while y_ quietly falls through to indeterminate. Reviewers scanning a constructor’s initializer list tend to check “does every parameter map to a member,” not “does every member appear somewhere,” so a member that was simply never wired up at all is easy to miss. A default member initializer on every data member — even ones you expect every constructor to set explicitly — is a cheap insurance policy against exactly this kind of omission.

class Dangerous {
    int x_;
    int y_;
    
public:
    Dangerous(int x) : x_(x) {}  // ❌ y_ is indeterminate!
};
// ✅ Fix
class Safe {
    int x_;
    int y_ = 0;  // Default member initializer
    
public:
    Safe(int x) : x_(x) {}  // y_ gets default value
};

Arrays

Arrays follow the exact same scalar-vs-brace-init split as single variables, just applied element-wise — int arr1[5] leaves every one of the five ints indeterminate, because an array has no constructor of its own to run and each element is default-initialized independently following the scalar rule. The empty-braces form int arr2[5]{} is worth understanding precisely: it’s not “call some array initializer,” it’s “value-initialize every element,” and for int that means zero. int arr3[5]{1} shows the aggregate initialization rule that trips people coming from other languages — any elements you don’t explicitly list in the braces are still value-initialized (zeroed), they’re not left indeterminate just because the array as a whole had some initializer.

void func() {
    int arr1[5];     // ❌ All elements indeterminate
    int arr2[5]{};   // ✅ All elements zero-initialized
    int arr3[5]{1};  // ✅ {1, 0, 0, 0, 0}
    
    // ❌ Reading uninitialized array
    for (int i = 0; i < 5; ++i) {
        std::cout << arr1[i] << "\n";  // UB
    }
}

Dynamic allocation

new inherits the same distinction that plain declarations have, but the syntax that triggers each behavior is easy to mix up under pressure: the parenthesized or braced form (new int(), new int{}) requests value initialization and zeroes the memory, while the bare form (new int) requests default initialization and leaves it indeterminate — a single pair of parentheses is the entire difference between defined and undefined behavior for the value you get back. Array-new behaves the same way at the granularity of the whole array: new int[5]() zero-initializes every element in one pass, whereas new int[5] leaves all five indeterminate. Because heap memory is far more likely than stack memory to contain genuinely unpredictable leftover bytes from unrelated previous allocations, forgetting the parentheses here tends to produce bugs that are even less reproducible than the local-variable case.

// Default initialization
int* p1 = new int;      // ❌ Indeterminate value
int* p2 = new int();    // ✅ Zero-initialized
int* p3 = new int{};    // ✅ Zero-initialized
int* p4 = new int(42);  // ✅ Initialized to 42
// Arrays
int* arr1 = new int[5];    // ❌ All indeterminate
int* arr2 = new int[5]();  // ✅ All zero-initialized
int* arr3 = new int[5]{}; // ✅ All zero-initialized
// Cleanup
delete p1;
delete[] arr1;

Conditional Initialization, memset, and Assuming Zero

Conditional initialization

// ❌ Bug
void process(bool flag) {
    int result;
    
    if (flag) {
        result = 42;
    }
    
    std::cout << result << "\n";  // UB if flag is false
}
// ✅ Fix
void process(bool flag) {
    int result = 0;  // Default value
    
    if (flag) {
        result = 42;
    }
    
    std::cout << result << "\n";
}

Using memset on non-trivial types

memset operates purely on raw bytes with no awareness of C++ object semantics, so it has no idea that std::string internally manages a pointer to heap-allocated character data (or, for short strings, a small-buffer-optimization layout with its own invariants). Zeroing that memory out from under an already-constructed std::string doesn’t reset it to an empty string — it corrupts the object’s internal representation in a way that violates invariants the destructor relies on, so the resulting behavior when d is later destroyed or used is undefined, not merely “wrong.” The rule of thumb: memset is only ever safe on types that are trivially copyable with no internal pointers or invariants (plain structs of scalars); anything containing a std::string, std::vector, or similar RAII type must be initialized (or cleared) through its own constructors and assignment operators instead.

struct Data {
    std::string name;
    int count;
};
// ❌ WRONG: Destroys std::string
Data d;
memset(&d, 0, sizeof(d));
// ✅ Correct
Data d{};  // Value-initialize all members

Assuming zero

static (and thread-local and global) variables genuinely are guaranteed zero-initialized before any dynamic initialization runs — that guarantee is real and safe to rely on, which is exactly why it’s dangerous to build a mental habit around it. The bug this section highlights is applying that same “static variables start at zero” intuition to an automatic-storage local declared right next to it — static int counter; and int local; look almost identical on the page, but only one of them carries the storage-duration guarantee. Code that has worked correctly for a static variable and then gets refactored (the static keyword accidentally dropped, or the variable moved into a different scope) can silently lose its zero-initialization guarantee without any compiler warning, because the resulting code — a plain uninitialized local — is completely legal C++.

// ❌ Wrong assumption
void increment() {
    static int counter;  // ✅ Zero-initialized (static)
    int local;           // ❌ Indeterminate (automatic)
    
    ++counter;  // OK: counter starts at 0
    ++local;    // UB: local is indeterminate
}

Detecting uninitialized variables

Because uninitialized-read bugs are undefined behavior rather than guaranteed crashes, they’re exactly the class of bug that testing alone tends to miss — the program can pass every test on the developer’s machine and still fail unpredictably once deployed to hardware or a build configuration where the leftover stack bytes happen to differ. The three tools below catch the problem at three different stages (compile time, runtime, and static analysis), and using more than one is genuinely worthwhile since each has blind spots the others cover.

Compiler warnings

-Wuninitialized performs flow-sensitive analysis to catch the simple, provably-always-uninitialized cases (like the accumulator bug above) directly at compile time with zero runtime cost — but it’s conservative by design and won’t catch every path-dependent case, particularly ones that flow through multiple functions or complex control flow, which is why it’s a first line of defense rather than a complete guarantee.

# GCC/Clang
g++ -Wall -Wextra -Wuninitialized -O2 file.cpp
# MSVC
cl /W4 /analyze file.cpp

Runtime detection with sanitizers

Unlike a compile-time warning, MemorySanitizer and Valgrind actually track, at the bit level, whether each byte of memory has ever been written to, and flag the exact instruction where an uninitialized byte is first read — which catches cases compile-time analysis misses (uninitialized reads that only happen on certain control-flow paths triggered by specific input) at the cost of running the program under heavy instrumentation, typically 2-3x slower for MemorySanitizer and considerably more for Valgrind. --track-origins=yes is worth calling out specifically: it makes Valgrind report not just where the uninitialized read happened but where the memory was allocated without being initialized, which is usually the more useful of the two locations when the read and the original declaration are far apart in the code.

# Memory Sanitizer (Clang)
clang++ -fsanitize=memory -g file.cpp
./a.out
# Valgrind
g++ -g file.cpp
valgrind --track-origins=yes ./a.out

Static analysis

Static analyzers occupy the middle ground: unlike sanitizers, they don’t need the buggy code path to actually execute to flag it, since they reason about the source directly rather than instrumenting a running program — which means they can catch uninitialized-variable risks in rarely-exercised error branches that a test suite might never actually trigger. The cppcoreguidelines-init-variables check specifically enforces the “always initialize on declaration” rule from the C++ Core Guidelines, flagging every bare declaration regardless of whether the analyzer can prove a read-before-write actually occurs — a stricter, more conservative net than the compiler’s own flow analysis.

// Clang-Tidy
clang-tidy file.cpp -checks='cppcoreguidelines-init-variables'

Initialization Habits That Prevent These Bugs

Always initialize variables

// ✅ Good
int count = 0;
double value = 0.0;
bool flag = false;
int* ptr = nullptr;
std::string name;  // Empty string (constructor)

Use value initialization for containers

This is a point worth correcting rather than just illustrating: std::vector’s sizing constructor vector(size_type count) does not leave elements indeterminate the way a bare int x; does. It “default-inserts” each element, which the standard defines as constructing it via allocator_traits::construct(alloc, p) with no extra arguments — and that call expands to ::new (p) T(), which is value initialization (note the parentheses), not default initialization. For a scalar T like int, that means every element of std::vector<int> vec(100) is already zero, with no explicit , 0 required. Where this genuinely bites people is with a struct that has scalar members and a user-declared (even = default) constructor: the vector still value-initializes each element object, but that just means “run the constructor,” and if the constructor’s body or initializer list doesn’t touch every member, those members are exactly as indeterminate as they’d be anywhere else — the container’s guarantee covers constructing the elements, not filling in gaps a poorly written constructor leaves behind.

std::vector<int> vec(100);    // Elements are already 0 (value-initialized)
std::vector<int> vec(100, 0); // Also 0, just explicit about it

Initialize in declaration

Separating declaration from initialization doesn’t just look worse — it reopens the exact window this whole article is about. In the “many lines” gap between int x; and x = 10;, x is genuinely indeterminate and readable by any code that executes in between (an early return, an exception, a branch that reads x before the assignment line is reached). Collapsing the two into int x = 10; doesn’t just save a line, it eliminates the possibility of that window existing at all.

// ❌ Separate declaration and initialization
int x;
// ... many lines ...
x = 10;
// ✅ Initialize immediately
int x = 10;

Use default member initializers

class Config {
    int timeout = 30;           // ✅ Default value
    bool enabled = true;        // ✅ Default value
    std::string name = "default"; // ✅ Default value
    
public:
    Config() = default;
};

Performance considerations

Myth: “Initializing to zero wastes performance” Reality: Modern compilers optimize away unnecessary initialization. This myth is understandable — it’s exactly true for the unoptimized build, where x = 0 genuinely does emit a real store instruction before it gets immediately overwritten. But at -O2 and above, the compiler performs dead-store elimination: it can see, within the same function, that the zero written to x is never read before x = computeValue() overwrites it, and simply removes the now-provably-useless store. The two versions below compile to identical machine code once optimizations are enabled — the “safety” of explicit initialization costs nothing in a release build, and the only place initializing to zero could theoretically cost something is a debug build, where nobody should be measuring performance in the first place.

// No performance difference with optimization
int x = 0;
x = computeValue();  // Compiler elides the zero initialization

Benchmark (GCC -O2, 1M iterations):

CodeTime
int x; x = f();2.1ms
int x = 0; x = f();2.1ms

Identical performance!


Compiler support

CompilerUninitialized warningsSanitizers
GCC4.0+4.8+ (ASan)
Clang3.0+3.1+ (MSan)
MSVCAll versions2019+ (ASan)