Skip to content
C++
Language
since C++98
Advanced

Undefined Behavior

C++ undefined behavior — what it is, how compilers exploit it, the most dangerous forms, and how to detect and eliminate it.

Undefined Behaviorsince C++98

A program that executes an operation the C++ standard designates as undefined behavior gives up all guarantees — the compiler may generate any code it chooses, including code that silently corrupts data, eliminates safety checks, or introduces security vulnerabilities.

Overview

Undefined behavior is not a runtime failure mode — it is a compile-time contract violation the optimizer exploits. The standard deliberately leaves certain operations undefined so compilers can optimize without disproving impossibility. When you write x + 1 where x is a signed int, the compiler is permitted to assume that expression never overflows, and it will restructure your code around that assumption.

The core danger: UB does not produce "garbage output." It produces code whose behavior the optimizer derived from a false premise. The result can appear correct in debug builds, fail silently under -O2, and change behavior across compiler versions — without any diagnostic.

The standard defines four tiers below "defined":

TermMeaning
Undefined behaviorNo requirements — anything may happen
Unspecified behaviorOne of several valid outcomes; standard doesn't say which
Implementation-definedPlatform must pick one outcome and document it
Erroneous behavior (C++26)UB with mandatory diagnostic support in hardened implementations

C++26 introduces erroneous behavior for operations like reading an uninitialized bool — conforming implementations must either produce the indeterminate value deterministically or trap, closing some of the practical UB gap without full defined-behavior overhead.

How compilers exploit UB

The optimizer operates under the as-if rule: it may rewrite any code that preserves observable behavior for a valid program. Because UB cannot occur in a valid program by definition, any path reachable only via UB is provably dead and may be eliminated.

cpp
// Overflow check the optimizer deletes entirely
bool will_overflow(int x) {
    return x + 1 <= x;   // signed overflow is UB — so x+1 > x always
}
// -O2 output (GCC 14, Clang 18): return false;
cpp
// Null check after dereference — optimizer proves it's dead code
void write(int* p) {
    *p = 42;              // dereference: establishes p != null
    if (p == nullptr)     // provably false — eliminated
        log_error();
}
cpp
// Signed loop counter — wrap assumed never to occur; termination check dropped
for (int i = 0; i >= 0; ++i) {   // UB: wraps to INT_MIN
    process(i);
}
// GCC -O3: may generate an infinite loop — the "i >= 0" check is always true

These are not compiler bugs. They are correct transformations of a program that has already violated the standard.


Examples

Signed integer overflow

Signed arithmetic is not modular in C++. Two's complement wrapping that C programmers sometimes rely on is undefined behavior — and C++ only mandated two's complement representation in C++20.

cpp
int x = INT_MAX;
int y = x + 1;            // UB: signed overflow (pre-C++20 and C++20+)

// Safe alternatives:
if (x > INT_MAX - 1) throw std::overflow_error("overflow");
long long y = static_cast<long long>(x) + 1;   // promote before adding
int y = std::add_sat(x, 1);                     // C++26: saturating arithmetic

Unsigned integers wrap by definition — that is fully specified behavior:

cpp
unsigned u = UINT_MAX;
unsigned v = u + 1;       // 0 — defined, guaranteed modular wrap

Out-of-bounds access

cpp
int arr[5];
int x = arr[5];           // UB: one past end
arr[-1] = 0;              // UB: before start

std::vector<int> v{1, 2, 3};
v[10];                    // UB: operator[] is unchecked
v.at(10);                 // C++98: throws std::out_of_range — use at trust boundaries

Dangling references and use-after-free

cpp
int& bad() {
    int x = 42;
    return x;             // UB: x destroyed at return
}

// Iterator invalidation — common and silent
std::vector<int> v{1, 2, 3};
auto it = v.begin();
v.push_back(4);           // may reallocate — it is now dangling
*it = 99;                 // UB

// Fix: hold indices instead of iterators across mutations
// Or: v.reserve(N) before inserting, when final size is known

Uninitialized reads

cpp
int x;                    // indeterminate value — not zero-initialized
if (x > 0) { ... }       // UB: reading indeterminate value

// C++26 erroneous behavior: reading uninitialized trivial types becomes
// erroneous (not UB) — implementations may produce 0 or trap

Strict aliasing violations

The strict aliasing rule permits the compiler to assume pointers of different types do not alias, enabling load/store reordering that breaks programs that violate it. The only types that may alias any object are char*, unsigned char*, and (C++17) std::byte*.

cpp
float f = 3.14f;
int* p = reinterpret_cast<int*>(&f);
*p = 0x3f800000;          // UB: accessing float storage through int*

// Correct type punning:
int bits;
std::memcpy(&bits, &f, sizeof(float));        // always defined
int bits = std::bit_cast<int>(f);             // C++20: preferred, constexpr-capable

std::bit_cast (C++20) is the canonical solution — it is constexpr, communicates intent, and generates identical code to memcpy on optimized builds.

Shift overflow

cpp
int x = 1;
x << 31;    // UB pre-C++20: shifts into sign bit for signed int
x << 32;    // UB all versions: shift amount >= bit width

// C++20 change: left-shift of signed integers is defined as
// truncation (two's complement) — shifting into sign bit is no longer UB
// C++20: (1 << 31) is defined for 32-bit int as INT_MIN

uint32_t u = 1u << 31;   // Always defined: 0x80000000u

Data races

cpp
int counter = 0;
std::thread t1([&]{ ++counter; });   // C++11: std::thread introduced
std::thread t2([&]{ ++counter; });
t1.join(); t2.join();
// UB: concurrent non-atomic writes — C++11 memory model defines this as UB

// Fix: std::atomic<int> counter{0};  // C++11

Detection

ToolCatchesFlag
UBSanSigned overflow, null deref, invalid enum, OOB-fsanitize=undefined
ASanOOB access, use-after-free, stack use-after-scope-fsanitize=address
MSanUninitialized reads-fsanitize=memory
TSanData races-fsanitize=thread
ValgrindOOB, use-after-free, leaksNo recompile needed
clang-tidyStatic: uninitialized vars, aliasing patternsCompile-time
bash
# Full sanitizer build — use this for CI on test suites
clang++ -fsanitize=address,undefined -fno-sanitize-recover=all \
        -g -O1 -o myapp main.cpp

# -fno-sanitize-recover=all: first UB hit aborts with stack trace
# -O1: enough optimization to trigger UB, cheap enough for CI

-fsanitize-trap=undefined (Clang) emits a hardware trap instead of a runtime call — useful for embedded targets that cannot link the sanitizer runtime.


Best Practices

  • Enable ASan + UBSan in CI. Run every test suite under -fsanitize=address,undefined -fno-sanitize-recover=all. Most dynamic UB is caught here at near-zero maintenance cost.
  • Test at -O2, not just debug. Many UB manifestations only appear when the optimizer runs. Debug builds mask the problem.
  • Use std::bit_cast for type punning (C++20). It's constexpr, expresses intent, and compiles to zero overhead.
  • Prefer .at() over [] at trust boundaries — where input size comes from external sources. The bounds check is a single branch.
  • Use std::atomic for any shared variable (C++11). Even reads of non-atomic data are UB when another thread writes.
  • Initialize all variables at declaration. Modern optimizers eliminate redundant stores; the bug risk from skipping initialization is not worth it.

Common Pitfalls

Signed/unsigned comparison. int i = -1; if (i < v.size()) — size() returns size_t (unsigned); -1 promotes to SIZE_MAX. Not UB, but almost always a bug. Compile with -Wsign-compare.

std::optional access without checking (C++17):

cpp
std::optional<int> opt;   // C++17
int x = *opt;             // UB: accessing disengaged optional
int x = opt.value();      // throws std::bad_optional_access — safer at trust boundaries

Placement new without std::launder (C++17). Reusing storage via placement new and then accessing through the original pointer is UB:

cpp
alignas(int) char buf[sizeof(int)];
new (buf) int{42};
int* p = reinterpret_cast<int*>(buf);          // UB: stale pointer
int* p = std::launder(reinterpret_cast<int*>(buf));  // C++17: defined

Assuming sanitizers catch everything. MSan misses some heap uninitialized reads; UBSan typically misses strict aliasing violations (-fsanitize=strict-aliasing is rarely enabled); neither tool catches logic errors. Sanitizers are a floor, not a ceiling.

Integer overflow in security-sensitive length arithmetic.

cpp
// Classic heap overflow via wrapping length
uint32_t len = attacker_controlled;
char* out = new char[len + 1];    // len = UINT_MAX → +1 wraps to 0
memcpy(out, src, len);            // copies UINT_MAX bytes — heap corruption

// Fix:
if (len > MAX_SAFE_SIZE) return error;
char* out = new char[len + 1];

Strict Aliasing Rule and Type Punning

Claim. Accessing an object through a pointer of an incompatible type is undefined behavior — the compiler is allowed to assume pointers of different types never alias the same memory, enabling aggressive load/store reordering.

The only pointer types that may legally alias any other object are char*, unsigned char*, and std::byte* (C++17). Everything else is subject to the strict aliasing rule.

cpp
// Classic (but UB) float→int bit-cast via pointer reinterpret
float f = 3.14f;
int bits = *reinterpret_cast<int*>(&f);   // UB: strict aliasing violation

memcpy-based punning is the portable pre-C++20 solution — it is always defined because copying through char* is explicitly permitted:

cpp
// Pre-C++20: memcpy punning — always defined
float f = 1.0f;
int bits;
std::memcpy(&bits, &f, sizeof(bits));     // defined behavior, compiles to a register move
cpp
// C++20
#include <bit>
float f = 1.0f;
int bits = std::bit_cast<int>(f);         // C++20: constexpr-capable, zero overhead

std::bit_cast is the canonical modern solution. It is constexpr, communicates intent at the call site, and generates identical machine code to memcpy on optimized builds. Passing -fno-strict-aliasing to GCC/Clang disables the aliasing optimization model entirely and makes the reinterpret_cast form safe — but it also forfeits significant optimization opportunities across the whole translation unit.


Signed Integer Overflow and Shift UB

Claim. Signed integer arithmetic is not modular in C++ — the compiler is permitted to assume signed overflow never occurs, and it derives loop and branch transformations from that assumption.

cpp
// INT_MAX + 1 is UB; the optimizer assumes it never happens
int x = INT_MAX;
int y = x + 1;     // UB: the compiler may assume this path is unreachable

// Loop vectorization enabled by overflow UB:
// Compiler assumes i*stride never wraps, so it can vectorize freely
for (int i = 0; i < n; i++) a[i] = i * 2;

Shift operations carry their own UB categories: left-shifting a signed value into or past the sign bit was UB before C++20, and shifting by an amount greater than or equal to the bit width is UB in all versions.

cpp
int x = 1;
x << 32;           // UB: shift amount >= bit width (all versions)
x << 31;           // UB pre-C++20: shifts into sign bit for 32-bit signed int
                   // C++20: defined as two's complement truncation

uint32_t u = 1u;
u << 31;           // always defined: 0x80000000u

Safe alternatives for checked and saturating arithmetic:

cpp
// GNU extension / GCC + Clang builtin
int result;
if (__builtin_add_overflow(a, b, &result)) { /* handle */ }

// C++26
#include <numeric>
int sat = std::add_sat(INT_MAX, 1);  // C++26: returns INT_MAX, no UB

// Runtime trap on overflow (debug/CI builds)
// Compile with: clang++ -ftrapv ...     (aborts on signed overflow)
//               clang++ -fsanitize=undefined ...  (UBSan reports + aborts)

-ftrapv instruments every signed arithmetic operation to abort on overflow; it is too slow for production but invaluable in test suites. UBSan (-fsanitize=signed-integer-overflow) gives the same safety with richer diagnostics.


UB Enabling Compiler Optimizations (Both Good and Bad)

Claim. Undefined behavior is not purely a safety hazard — it is a deliberate contract that enables optimizations that defined-behavior languages cannot perform, but the same mechanism can silently remove code you intended to execute.

A useful optimization: signed overflow UB lets the compiler treat loop induction variables as unbounded, enabling auto-vectorization and strength reduction:

cpp
// Compiler can vectorize: assumes i never wraps, so SIMD widening is valid
void scale(int* a, int n) {
    for (int i = 0; i < n; i++) a[i] = i * 2;
}
// With unsigned i, the wrap assumption is gone — some vectorizers emit wider code

A dangerous optimization: dereferencing a pointer before a null check lets the compiler eliminate the check entirely.

cpp
void write(int* ptr, int val) {
    *ptr = val;            // dereference establishes: ptr != null
    if (ptr == nullptr)    // provably false — dead code, eliminated at -O2
        log_error();
}
// The null check is gone in the binary. If ptr is null, the store crashes silently.

Branch-prediction hints interact with this: [[likely]] and [[unlikely]] (C++20) annotate probabilities the optimizer uses for layout and speculative execution, but they cannot override UB-derived dead-code elimination — if UB has already made the branch unreachable, the hint has no effect.

cpp
// C++20
void process(int* p) {
    if (p == nullptr) [[unlikely]] return;   // C++20: hint, not a guarantee
    *p = 42;
}
// Safe: null check precedes the dereference — no UB, hint is respected

Detecting UB at Compile Time and Runtime

Claim. The best UB is the kind caught before the binary ships — and C++ provides escalating layers of detection from constexpr evaluation at compile time through sanitizers at runtime.

constexpr evaluation is a zero-cost UB detector: many forms of UB that are silent at runtime become hard compile errors inside a constexpr context:

cpp
// C++20
constexpr int divide(int a, int b) { return a / b; }
constexpr int x = divide(1, 0);   // C++20: compile error — division by zero in constexpr

std::is_constant_evaluated() lets you write one function that is strict at compile time and fast at runtime:

cpp
// C++20
#include <type_traits>
constexpr int safe_div(int a, int b) {
    if (std::is_constant_evaluated()) {
        // division by zero here is a compile error
    }
    return a / b;
}

UBSan runtime detection covers the most common dynamic UB categories. Compile with -fsanitize=undefined -fno-sanitize-recover=all to abort with a stack trace on first hit:

bash
# Example UBSan output
runtime error: signed integer overflow: 2147483647 + 1 cannot be
  represented in type 'int'
    #0 0x401234 in main main.cpp:5

The full Clang -fsanitize=undefined checklist includes: signed-integer-overflow, shift, null, alignment, bool, enum, vptr, bounds, object-size, and pointer-overflow. Enable them individually if the full suite is too slow for your test suite. Compile-time attributes [[nodiscard]] and [[deprecated]] are UB-adjacent — they don't flag UB directly, but they catch ignored return values and stale APIs that commonly lead to it.


Implementation-Defined and Unspecified Behavior

Claim. Not all non-portable behavior is undefined — the standard defines a strict hierarchy, and confusing these tiers leads either to unnecessary portability paranoia or to real UB disguised as "it works on my machine."

The three tiers below fully defined behavior:

TierGuaranteeExample
Implementation-definedCompiler picks one outcome and documents itsizeof(int), sign of % for negative operands
UnspecifiedOne of several valid outcomes; compiler need not document whichOrder of function argument evaluation, value of uninitialized storage
UndefinedNo requirement — optimizer may assume it never occursSigned overflow, data race, null dereference

sizeof(int) is implementation-defined: on LP64 Linux it is 4, on ILP64 it may be 8, but the platform must document the choice. Relying on it is non-portable but not UB:

cpp
// Implementation-defined: 4 on most 64-bit platforms, not UB
static_assert(sizeof(int) >= 2);   // minimum guaranteed by standard

Function argument evaluation order was unspecified in C++14 — each call to f(a(), b()) could evaluate a() or b() first, giving different results with side effects. C++17 tightened sequencing rules for postfix expressions but left general argument order unspecified:

cpp
// Unspecified in C++14, still unspecified in C++17
int i = 0;
f(i++, i++);      // unspecified which increment happens first

// C++17 sequencing improvement: chained calls are now left-to-right
a.b().c().d();    // C++17: a.b() is fully evaluated before .c()

The C++17 sequencing changes also fixed the classic std::string expression evaluation problem — s.replace(…).replace(…) now has a guaranteed left-to-right order for member-function chains.


Mitigating UB: Sanitizers, Static Analysis, and Safe Types

Claim. No single tool catches all UB — a defense-in-depth workflow combining compile-time constraints, CI sanitizers, and narrow production checks is the industry standard.

The core debug/CI build flags:

bash
# Recommended CI build (Clang or GCC)
clang++ -fsanitize=address,undefined \
        -fno-sanitize-recover=all \
        -g -O1 \
        -o myapp main.cpp

# address: OOB, use-after-free, stack UAF
# undefined: signed overflow, null deref, bad shift, invalid enum
# -fno-sanitize-recover=all: abort on first violation (no silent continuation)
# -O1: enough optimization to trigger UB, cheap enough for CI

Static analysis catches structural patterns sanitizers miss:

bash
clang-tidy myapp.cpp -- -std=c++20   # checks: cppcoreguidelines-*, bugprone-*
cppcheck --enable=all --std=c++20 myapp.cpp

Safe type wrappers eliminate whole categories of UB at their source:

cpp
// C++20
#include <gsl/narrow>
#include <span>

// gsl::narrow<T>: checked narrowing cast — throws on value change
int big = 300;
uint8_t small = gsl::narrow<uint8_t>(big);   // throws: 300 doesn't fit in uint8_t

// std::span: bounds-checked view over contiguous data (C++20)
void process(std::span<int> data) {
    data[0];          // bounds-checked in hardened mode; no raw pointer arithmetic
}

// std::array over raw arrays: size always known, .at() available
std::array<int, 4> arr{1, 2, 3, 4};
arr.at(5);            // throws std::out_of_range — no silent OOB

Defense-in-depth matrix:

LayerMechanismCost
Compile-timeconcepts, static_assert, constexpr evalZero runtime cost
CI / DebugASan + UBSan, -fno-sanitize-recover=all~2× slower, CI only
Code reviewclang-tidy, cppcheck, core guidelinesDeveloper time
Production boundariesgsl::narrow, .at(), explicit range checksSingle branch per call
Production interiorTrust validated data; no sanitizer overheadZero extra cost

Sanitizers are a floor, not a ceiling. UBSan misses strict aliasing violations by default; MSan misses some heap patterns; neither tool catches logic errors. The goal is to make UB loud in development and structurally impossible in production-boundary code.


See Also