C++ Undefined Behavior : Why Release-Only Crashes Happen

Key takeaways

Because the compiler may assume undefined behavior never happens, optimized builds can delete checks or reorder code, so a bug that is silent in debug crashes in release. The post contrasts UB with implementation-defined behavior, shows typical patterns and case studies, and covers UBSan and other sanitizers.

UB often surfaces as crashes—see segmentation fault debugging.

Introduction: “Debug is fine; Release crashes”

Undefined behavior (UB) means the C++ standard imposes no requirements on what happens. Compilers assume UB does not occur and may optimize aggressively.

int arr[5] = {1, 2, 3, 4, 5};
int x = arr[10];  // UB: out-of-bounds read

This article covers:

  • Fifteen common UB patterns (grouped)
  • Why Debug and Release differ
  • UBSan and complementary tools
  • How optimizations interact with UB
  • Short case studies

What is undefined behavior?

Definition

If a program has undefined behavior, any outcome is allowed: crash, wrong results, or “impossible” optimizations.

UB vs implementation-defined vs unspecified

TermMeaning
UndefinedNo standard guarantee
Implementation-definedCompiler documents behavior
UnspecifiedOne of several allowed outcomes

The distinction is practical, not pedantic. sizeof(long) is implementation-defined: it is 4 on 64-bit Windows and 8 on 64-bit Linux, but each compiler documents and sticks to its choice, so a program depending on it is merely non-portable. The order in which f(a(), b()) evaluates a() and b() is unspecified: either order may happen, and the program must work with both, but nothing else can go wrong. Undefined behavior is different in kind: the standard says nothing about the whole program’s behavior once UB occurs, which lets the optimizer treat “this cannot happen” as a fact and reason backwards from it. That is why UB can affect code that runs before the faulty line, and why its effects change with compiler versions and flags.

The reason C++ has so much UB is performance and portability. Requiring a bounds check on every array access, a defined result for every overflow, or a zero-initialization of every local would cost time on every platform, so the language instead makes these the programmer’s responsibility and lets the compiler generate code as if the rules are always followed.


UB patterns (representative)

  1. Out-of-bounds access
  2. Null pointer dereference
  3. Dangling pointer use
  4. Reading uninitialized automatic variables
  5. Signed integer overflow (int)
  6. Violating strict aliasing rules
  7. Using an object outside its lifetime
  8. Data races
  9. Mismatched allocation/deallocation
  10. Unsequenced conflicting side effects on the same scalar
  11. Invalid pointer arithmetic and dereference
  12. Misaligned access via casts (platform-dependent)
  13. Calling a pure virtual function during construction/destruction (an ordinary virtual call there is defined: it dispatches to the class currently being constructed, not the derived override)
  14. Modifying string literals
  15. Type punning without memcpy/std::bit_cast where required

A few of these deserve a concrete look, because they are the ones that pass code review:

// 4. Uninitialized read: may print 0 in debug, garbage (or take both branches) in release
int total;
for (int v : values) total += v;

// 7. Lifetime: the temporary string dies at the end of the full expression
const char* name = std::string("temp").c_str();
std::puts(name);                 // dangling

// 3. Dangling reference into a vector that reallocates
std::vector<int> v{1, 2, 3};
int& first = v[0];
v.push_back(4);                  // may reallocate
first = 10;                      // UB if it did

// 6/15. Strict aliasing: reading a float's bits through an int*
float f = 1.0f;
int bits = *reinterpret_cast<int*>(&f);   // UB
int ok   = std::bit_cast<int>(f);         // C++20, defined (or memcpy before C++20)

// 10. Unsequenced modification
int i = 0;
i = i++ + 1;                     // UB before C++17 (well-defined since C++17)
a[i] = i++;                      // well-defined since C++17; still confusing, avoid

The vector case is one of the most common in real codebases, usually in a more hidden form: a reference or iterator obtained from a container, followed by a function call that, several layers down, inserts into the same container. It works in tests with small data (no reallocation happens) and corrupts memory under production load.


Debug vs Release

Debug often initializes stack slots or uses patterns that mask bugs; Release may leave variables uninitialized and optimize using “UB cannot happen” reasoning.

Several concrete mechanisms produce the “works in Debug” effect. MSVC debug builds fill uninitialized stack memory with 0xCC and freed heap memory with 0xDD, and add runtime checks (/RTC) that catch some uninitialized uses; debug standard libraries check iterator validity and bounds in operator[]. At -O0, every variable lives in its own stack slot and is reloaded from memory on every use, so an out-of-bounds write lands in a predictable place and a value is never cached in a register. At -O2, variables share slots and registers, loops are vectorized, and functions are inlined, so the same bug overwrites something else, or the compiler removes code whose only purpose would be observable after UB. The bug was there in both builds; only its visibility changed.

A useful reflex when something breaks only in release is to build release with sanitizers and debug info (-O2 -g -fsanitize=address,undefined), rather than trying to debug the optimized binary directly. The sanitizer reports the first violation with a stack trace, which is usually far earlier than the crash.


UBSan

g++ -g -fsanitize=undefined -std=c++17 -o myapp main.cpp
./myapp

Combine with -fsanitize=address and -fsanitize=thread where appropriate (separate builds often).

UBSan inserts a check before each operation that could be undefined and prints a report such as main.cpp:12:9: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'. By default it reports and continues, so one run lists many problems; add -fno-sanitize-recover=all in CI so the first report fails the test, and set UBSAN_OPTIONS=print_stacktrace=1 for stack traces. Its overhead is modest, much lower than Valgrind, so it can stay on for the entire test suite.

Know what each tool does not cover. UBSan checks arithmetic, shifts, misaligned pointers, invalid enum/bool values, null dereferences and some bounds (-fsanitize=bounds for arrays of known size), but not heap buffer overflows or use-after-free; that is ASan’s job. Uninitialized reads need MemorySanitizer (Clang only, requires instrumenting all code including libraries) or Valgrind. Data races need TSan, which cannot be combined with ASan in one build. And all of them only find UB on code paths the test actually executes, so they complement, rather than replace, warnings like -Wall -Wextra and static analysis such as clang-tidy.


Compiler optimization and UB

Examples: null checks that become unreachable after illegal dereference assumptions; x + 1 > x treated as always true for signed int when overflow is assumed impossible.

int readFlag(Config* cfg) {
    int flags = cfg->flags;      // dereference first
    if (cfg == nullptr)          // compiler: cfg cannot be null here
        return -1;               // this branch may be deleted
    return flags;
}

bool willOverflow(int x) {
    return x + 1 < x;            // may compile to "return false"
}

for (int i = 0; i <= n; ++i) { /* ... */ }  // compiler may assume no overflow of i,
                                             // which enables vectorization

In each case the compiler is not being malicious; it applies a rule that is correct for every valid program. After cfg->flags, cfg must be non-null in any program without UB, so the check is dead code. For int, x + 1 < x can only be true through overflow, which is UB, so the result is false. The loop example shows why this matters for performance: because signed overflow cannot happen, the compiler knows the loop runs exactly n + 1 times and can vectorize it, which it could not safely do if i might wrap. The same freedom that makes the loop fast makes the broken overflow check vanish.

The fix is always to test before the undefined operation: check the pointer before dereferencing it, compare x < INT_MAX before adding, or use __builtin_add_overflow (GCC/Clang) or C++26’s std::add_sat. Flags such as -fwrapv (define signed overflow as wrapping) and -fno-delete-null-pointer-checks exist and are used by some projects, such as the Linux kernel, but they make code dialect-dependent and hide bugs rather than fixing them.


Case studies (short)

These are recurring patterns rather than specific incidents:

  • Game logic: health or score computed as int and decremented past INT_MIN, or multiplied into overflow. In debug it wraps and looks like a strange negative number; in release, comparisons involving the value can be folded away. Use a wider type, clamp explicitly, or use checked arithmetic.
  • Image processing: a 3x3 kernel reading pixels[y - 1][x - 1] at the image border. The out-of-bounds read usually returns neighboring memory and produces a thin line of wrong pixels, so it goes unnoticed until an ASan run or a crash on a small image. Loop over the interior only, or clamp coordinates.
  • Money and counters: summing prices in cents as int overflows at about 21 million currency units, which a busy day of transactions can reach. Use long long (or std::int64_t) for accumulators; the bug is invisible in tests with small data.
  • Bit manipulation: 1 << 31 on a 32-bit int is UB in C and was in C++11 (the result does not fit; C++14 and C++20 progressively defined it), and shifting by the type’s width or more (x << 32) is still UB. Use unsigned types (1u << 31) for bit masks.

Summary

UB prevention checklist

  • Initialize before read
  • Validate indices and lifetimes
  • Avoid signed overflow; use wider types or checks
  • Synchronize shared data
  • Match new[]/delete[]
  • No data races

Sanitizer overview

ToolFocus
UBSanMany UB rules
ASanMemory errors
TSanData races

Rules

  1. UB is not “bad luck.”
  2. Debug passing does not prove Release safety.
  3. Use sanitizers in CI.
  4. Treat warnings seriously.
  5. Prefer RAII and safe abstractions.


Catching undefined behavior early

The pattern that works is layered. Compile everything with -Wall -Wextra (and treat new warnings as errors), because warnings such as -Wuninitialized, -Warray-bounds and -Wreturn-type catch some UB for free. Run the test suite in CI in at least two sanitizer configurations, address,undefined and thread, built with optimizations on, since some UB only manifests in optimized code. And test the actual release configuration regularly, not only debug builds. None of these proves the absence of UB, but together they catch most of it before users do.


Closing

Undefined behavior is among the most dangerous C++ issues. Combine warnings, sanitizers, and sound types to ship reliable code. Next: RAII and smart pointers.



Frequently Asked Questions (FAQ)

Q. Why did the compiler remove my null-pointer or overflow check?

A. If a pointer is dereferenced before it is checked, the optimizer may assume it cannot be null, because dereferencing null would be undefined behavior, and drop the later check. Likewise, if (x + 1 < x) for a signed int can be folded to false, since signed overflow is UB. Do the check before the operation that would be undefined, for example compare against INT_MAX before adding, or use __builtin_add_overflow on GCC and Clang.