Undefined Behavior in C++

Undefined behavior is the single most tested-for concept in C++ interviews, because it's the gap between code that looks correct and code that actually is correct — and the standard gives compilers explicit permission to do anything at all once your program crosses that line.

C++ compilers are allowed to assume your program never triggers undefined behavior, and they aggressively use that assumption to optimize code — which means a program with undefined behavior isn't guaranteed to crash, or to produce a wrong answer, or to do anything predictable at all; it might work perfectly on your machine with your compiler and flags, and then corrupt memory or delete files when compiled differently. This is why 'but it worked when I ran it' is not evidence of correctness in C++, and why recognizing undefined behavior by sight is one of the most heavily interviewed C++ skills there is. This page catalogs the undefined behaviors you're most likely to be asked about — out-of-bounds access, dangling pointers and references, signed overflow, invalid shifts, data races, lifetime violations, strict aliasing, invalid casts, iterator invalidation, uninitialized reads, double deletion, and violated library preconditions — and draws the crucial three-way distinction between undefined, implementation-defined, and unspecified behavior.

What "Undefined Behavior" Actually Means

The C++ standard defines undefined behavior as behavior for which the standard imposes no requirements whatsoever. That's a much stronger statement than 'the result is unpredictable' — it means the compiler is free to assume the undefined-behavior-triggering code path is never reached at all, and it can optimize the rest of the program accordingly, including in ways that affect code nowhere near the actual bug.

This function is trying to detect overflow by checking whether adding 1 made the value smaller, but signed overflow is itself undefined behavior, so the compiler doesn't have to honor the programmer's intent at all — it can and sometimes does simplify x + 1 < x to false unconditionally, silently defeating the overflow check it was written to perform.

#include <climits>
#include <iostream>

bool will_overflow(int x)
{
    return x + 1 < x; // relies on signed overflow -- itself undefined behavior
}

int main()
{
    std::cout << will_overflow(INT_MAX) << '\n';
    // A compiler is entitled to assume signed overflow never happens, and may
    // conclude x + 1 < x is always false for any int x -- optimizing the
    // function down to `return false;` regardless of the actual runtime value.
}

UB vs Implementation-Defined vs Unspecified Behavior

These three terms sound similar but mean very different things, and mixing them up is a common interview stumble. Undefined behavior means the standard imposes no requirements at all — anything can happen, including things that look nothing like a crash. Implementation-defined behavior means the standard requires each compiler to pick a specific, consistent behavior and document what it chose — the size of int isn't fixed by the standard, but a given compiler on a given platform must consistently use the same size and document it. Unspecified behavior means the standard allows multiple valid behaviors and doesn't require documenting which one a given implementation picked — the order in which function arguments are evaluated is unspecified, so a compiler can pick left-to-right or right-to-left, and isn't obligated to tell you which, but it does have to pick a consistent, well-defined order rather than doing something arbitrary.

f(next(), next()) is unspecified, not undefined: the compiler must pick some fixed evaluation order and stick to it, it's just not required to tell you which, so the output is one of a small set of legal possibilities. The commented-out x++ + x++; line is genuinely undefined, since C++ doesn't guarantee a sequencing between the two increments of the same variable within one expression, and sizeof(int) is implementation-defined because it must be one fixed, documented value per compiler/platform, even though that value differs across compilers.

#include <iostream>

void f(int a, int b) { std::cout << a << " " << b << '\n'; }

int i = 0;

int next() { return i++; }

int main()
{
    f(next(), next()); // UNSPECIFIED: could print "0 1" or "1 0" depending on evaluation order

    int x = 5;
    // int y = x++ + x++; // UNDEFINED: modifying x twice with no sequence point between them

    // sizeof(int) is IMPLEMENTATION-DEFINED: the standard doesn't fix it,
    // but a given compiler must use one consistent, documented size.
}

Out-of-Bounds Access

Reading or writing past the bounds of an array, std::vector, or std::string is undefined behavior — operator[] on these types performs no bounds checking at all, in exchange for speed. There's no guaranteed crash; the access might silently read or corrupt whatever memory happens to sit past the end of the buffer.

v[3] compiles cleanly and might print a garbage value, might crash, or might appear to work — operator[] trusts the caller entirely. v.at(3) performs the same access with a bounds check and throws a well-defined std::out_of_range exception instead, at the cost of that check running on every access.

#include <vector>
#include <iostream>

int main()
{
    std::vector<int> v = {1, 2, 3};
    std::cout << v[3] << '\n'; // UB: index 3 is one past the last valid index (2)
    v.at(3);                    // throws std::out_of_range instead -- checked access
}

Dangling Pointers, Use-After-Free, and Dangling References

A pointer or reference is dangling once the object it referred to has been destroyed — the memory may have been reused for something else entirely, or freed back to the operating system. Dereferencing a dangling pointer or reference (a use-after-free) is undefined behavior, whether the object was on the heap (deleted or freed) or the stack (went out of scope).

dangling_from_heap returns the numeric value of p unchanged, but the memory it points to was already released by delete, so dereferencing it in main reads freed memory. dangling_from_stack is even easier to write by accident: local's storage no longer exists the instant the function returns, so the reference r is bound to nothing valid at all.

#include <iostream>

int *dangling_from_heap()
{
    int *p = new int(42);
    delete p;
    return p; // p is now a dangling pointer -- the pointed-to memory is freed
}

int &dangling_from_stack()
{
    int local = 10;
    return local; // UB: returning a reference to a local that's destroyed on return
}

int main()
{
    int *p = dangling_from_heap();
    std::cout << *p << '\n'; // UB: use-after-free

    int &r = dangling_from_stack();
    std::cout << r << '\n';  // UB: reading a destroyed local through a dangling reference
}

Signed Integer Overflow and Invalid Shifts

Unsigned integer arithmetic wraps around predictably (defined by the standard as modular arithmetic), but signed integer overflow is undefined behavior — adding 1 to INT_MAX has no defined result at all, unlike the common assumption that it 'wraps to a very negative number.' Bit shifts have their own undefined cases: shifting by a negative amount, or by an amount greater than or equal to the width of the type, is undefined, and left-shifting a negative signed value is undefined as well.

x + 1 is undefined the moment x is INT_MAX, even though many compilers happen to produce INT_MIN on common hardware — relying on that is relying on unspecified compiler behavior, not a language guarantee. u + 1 by contrast is fully defined to wrap to 0, because unsigned arithmetic is specified as modular. Both shift examples are undefined for reasons unrelated to overflow: shifting by an amount that meets or exceeds the type's bit width, and left-shifting a negative value, are both explicitly called out as undefined by the standard.

#include <climits>
#include <iostream>

int main()
{
    int x = INT_MAX;
    int y = x + 1;        // UB: signed overflow, NOT guaranteed to wrap to INT_MIN

    unsigned int u = UINT_MAX;
    unsigned int v = u + 1; // well-defined: wraps to 0, per modular arithmetic rules

    int shift_amount = 40;
    int z = 1 << shift_amount; // UB: shifting a 32-bit int by 40 (>= its width)

    int negative = -1;
    int bad = negative << 1;   // UB: left-shifting a negative signed value
}

Data Races

A data race — two threads accessing the same memory location concurrently, with at least one of them writing, and no synchronization between the accesses — is undefined behavior in C++, not merely 'a bug that produces a wrong number.' Unlike some languages that guarantee a race at least produces some valid (if unpredictable) value, C++ makes no such promise at all.

counter++ is a read-modify-write sequence (read the value, add one, write it back), and when two threads perform that sequence on the same plain int with no synchronization, their operations can interleave in ways that lose increments — but the standard doesn't just say the result is 'probably less than 200000,' it says the program's behavior is entirely undefined the moment the race occurs. Declaring counter as std::atomic<int> or protecting the increment with a mutex removes the race and restores well-defined behavior.

#include <thread>

int counter = 0;

void increment()
{
    for (int i = 0; i < 100000; ++i)
        counter++; // UB: unsynchronized read-modify-write from two threads
}

int main()
{
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    // counter's final value is undefined behavior, not just "probably wrong" --
    // fix with std::atomic<int> counter, or a mutex around the increment.
}

Lifetime Violations and Strict Aliasing

Accessing an object outside its lifetime — before its constructor has run, or after its destructor has run — is undefined behavior, even if the memory technically still exists. The strict aliasing rule is a related but distinct restriction: the compiler is allowed to assume that pointers of unrelated types never point to the same memory, so reinterpreting a pointer to one type as a pointer to a different, unrelated type and dereferencing it is undefined behavior, because it violates that assumption.

int and float are unrelated types, so the compiler is entitled to assume an int object and a float object never overlap in memory; reading *fp here violates that assumption even though ip and fp technically point at the same bytes. The standard-compliant way to reinterpret bits between types is std::bit_cast<float>(value) (C++20) or std::memcpy into a float variable, both of which are explicitly carved out as well-defined.

#include <cstdint>
#include <iostream>

float interpret_as_float(int value)
{
    int *ip = &value;
    float *fp = reinterpret_cast<float *>(ip); // aliasing violation
    return *fp; // UB: reading through a float* that actually points at an int
}

int main()
{
    std::cout << interpret_as_float(1065353216) << '\n'; // UB, not a reliable 1.0f
}

Invalid Casts, Uninitialized Reads, and Double Deletion

A handful of other well-known undefined behaviors round out the classic interview list. A static_cast or C-style cast down an inheritance hierarchy to a type the object isn't actually an instance of, followed by using the result, is undefined behavior — dynamic_cast at least fails safely (returning nullptr for pointers, or throwing std::bad_cast for references) instead of silently producing a bad pointer. Reading a variable before it's been initialized reads whatever indeterminate bits happen to be sitting in that memory, which is undefined behavior, not merely 'garbage but harmless.' And calling delete twice on the same pointer (double deletion, or double free) is undefined behavior, since the memory is only valid to release once — a second delete typically corrupts the allocator's internal bookkeeping.

static_cast trusts the programmer completely and performs no runtime check, so casting an Other* to Derived* compiles fine and then reads memory that was never laid out the way Derived expects. uninitialized has no defined value at all until it's assigned one — printing it reads whatever bit pattern happened to occupy that stack slot beforehand. The second delete p; operates on memory the allocator has already reclaimed and possibly reused, which is why double-free bugs often manifest as heap corruption far away from the actual delete statement.

#include <iostream>

struct Base { virtual ~Base() = default; };
struct Derived : Base { int extra = 5; };
struct Other : Base { };

int main()
{
    Base *b = new Other();
    Derived *d = static_cast<Derived *>(b); // compiles, but b isn't actually a Derived
    std::cout << d->extra << '\n';           // UB: reading a member that doesn't exist here

    int uninitialized;
    std::cout << uninitialized << '\n';       // UB: reading indeterminate memory

    int *p = new int(1);
    delete p;
    delete p; // UB: double deletion -- corrupts heap bookkeeping
}

Iterator Invalidation and Violating Library Preconditions

Many standard library operations invalidate existing iterators, pointers, or references into a container as a documented side effect — pushing onto a std::vector that needs to reallocate invalidates every iterator into it, and erasing an element from the middle of a std::vector invalidates iterators at or after the erased position. Continuing to use an invalidated iterator is undefined behavior. More broadly, nearly every standard library function documents preconditions (std::vector::front() requires the vector to be non-empty, std::sort requires random-access iterators forming a valid range), and calling them with those preconditions violated is undefined behavior, exactly the same way violating your own function's precondition would be.

push_back may need to allocate a larger buffer and move every element into it, which leaves it pointing at the old, freed buffer — whether this actually happens depends on the vector's current capacity, which is exactly why this bug is so easy to miss in testing (it might not trigger until the vector happens to be at capacity). empty_vec.front() is documented as requiring a non-empty container, and calling it on an empty one is undefined behavior rather than, say, a thrown exception.

#include <vector>
#include <iostream>

int main()
{
    std::vector<int> v = {1, 2, 3};
    auto it = v.begin();
    v.push_back(4); // may reallocate, invalidating `it`
    std::cout << *it << '\n'; // UB if a reallocation happened: `it` may be dangling

    std::vector<int> empty_vec;
    std::cout << empty_vec.front() << '\n'; // UB: precondition violated (vector is empty)
}

Why Compilers Are Allowed to Exploit Undefined Behavior

The reason UB is so consequential in practice, rather than a theoretical curiosity, is that optimizing compilers actively use the 'this can't happen' assumption to generate faster code — eliminating null checks after a dereference has already occurred (since dereferencing null is UB, the compiler assumes it didn't happen, so a later if (ptr == nullptr) check can look unreachable and get deleted), assuming loop induction variables never overflow, and reordering or eliminating code paths that are only reachable through UB. This is precisely why a program with UB can appear to 'work' at one optimization level and misbehave dramatically at a higher one, or after an unrelated code change shifts what the optimizer decides to do.

Catching Undefined Behavior Before It Ships

Because UB often doesn't crash immediately, catching it relies on tools rather than manual testing alone. Compiling with -fsanitize=undefined (UBSan) instruments a build to detect signed overflow, invalid shifts, and similar violations at runtime and report them immediately. -fsanitize=address (AddressSanitizer) catches out-of-bounds access, use-after-free, and double-free with a precise, human-readable report at the moment of the violation, rather than an unpredictable crash much later. -fsanitize=thread (ThreadSanitizer) detects data races. None of these are exhaustive — they only catch UB on code paths that are actually exercised — but running a test suite under sanitizers regularly is one of the highest-value habits a C++ developer can build, and it's routinely mentioned favorably in interviews as a sign of real production experience.

Why is it wrong to say a program with undefined behavior will "probably crash" or "probably just give a wrong answer"?

The standard imposes no requirements at all once undefined behavior occurs, and compilers actively exploit the assumption that it never happens to optimize code — including code far away from the actual violation. That means the observable result can be a crash, a silently wrong answer, a program that appears to work correctly, or something optimized away entirely, with no guarantee of which, and it can change between compilers, flags, or even unrelated code edits.

Explain the difference between unspecified behavior and undefined behavior using function argument evaluation order as the unspecified example.

Unspecified behavior means the standard allows several valid outcomes and doesn't require a compiler to document which one it picked, but the compiler must still choose one consistent, well-defined behavior and stick to it — evaluating f(next(), next())'s arguments in some fixed order every time, just not necessarily left-to-right. Undefined behavior, by contrast, imposes no requirements at all; there isn't even a guarantee that a consistent, well-defined outcome occurs.

Why can a data race between two threads incrementing the same plain int be worse than "the counter ends up a bit too low"?

An unsynchronized read-modify-write from two threads on the same memory location is a data race, and the C++ standard classifies data races as undefined behavior outright, not as a defined-but-imprecise operation. That means the standard makes no guarantee the final value is even in a sane range at all — in practice it's usually just a lower-than-expected count, but the language gives no such guarantee, which is why atomics or a mutex are required for a program to have well-defined behavior.

Why does reinterpreting an int* as a float* and dereferencing it violate the strict aliasing rule, and what's the standard-compliant alternative?

The strict aliasing rule lets the compiler assume pointers to unrelated types never refer to the same memory, so it can make optimization decisions based on that assumption; reading through a float* that actually points at an int's storage breaks that assumption and is undefined behavior even though the bytes are physically the same. std::bit_cast<float>(value) (C++20) or copying the bytes with std::memcpy into a genuine float variable are both explicitly defined ways to reinterpret the bits instead.

Why might v.push_back(4) invalidate an iterator obtained before the call, and why is this bug notoriously hard to catch through ordinary testing?

push_back may need to grow the vector's internal buffer once its current capacity is exceeded, which means allocating a new, larger buffer, moving every existing element into it, and freeing the old buffer — any iterator into the old buffer is left dangling. Whether this actually happens depends entirely on the vector's current capacity versus its size at that moment, so the same code can work fine in many test runs (while capacity happens to be sufficient) and only fail once the vector happens to be exactly at capacity, making the bug easy to miss without specifically testing around capacity boundaries or running under a sanitizer.

What is wrong with this function, and rewrite it to avoid the undefined behavior while preserving its intent of returning a pointer to a dynamically created string of greeting text?

result is a local std::string that's destroyed when make_greeting returns, so the pointer returned by result.c_str() dangles the instant the function returns — the caller receives a pointer to memory that has already been freed, and dereferencing it is undefined behavior (use-after-free). The fix is to return std::string result; directly by value instead of returning c_str(): std::string make_greeting(const std::string &name) { return "Hello, " + name + "!"; }, which returns ownership of the actual string data to the caller (aided by move semantics/RVO) instead of a pointer into memory that's about to be destroyed.

Identify the undefined behavior in this program and explain specifically why it's undefined rather than merely "probably fine." v.erase(it) removes the element it points to and invalidates it (and every iterator at or after that position) as a documented consequence of erase on a std::vector — the loop's ++it then increments an already-invalidated iterator, and the subsequent it != v.end() comparison operates on a dangling iterator, both undefined behavior. It's not 'probably fine' because whether it crashes, loops incorrectly, or appears to work depends on what the now-invalid iterator happens to still resemble in memory, which is exactly the kind of behavior the standard makes zero guarantees about. The fix is to capture erase's return value, which points to the element after the one removed: it = v.erase(it); combined with restructuring the loop to only advance manually when no erase happened.

Write a program demonstrating well-defined unsigned wraparound versus undefined signed overflow side by side, printing the unsigned result and explaining why the signed case cannot be relied upon to print a specific value.

u + 1 on an unsigned int at UINT_MAX is fully defined by the standard as modular arithmetic and always wraps to 0, so the program reliably prints 'unsigned wraps to: 0' on every conforming compiler. The commented-out signed case is left uncompiled deliberately: s + 1 at INT_MAX is undefined behavior, so there is no value — not even the commonly assumed INT_MIN — that the standard guarantees it produces, and a compiler is free to optimize based on the assumption that this line is never reached at all.

#include <string>

const char *make_greeting(const std::string &name)
{
    std::string result = "Hello, " + name + "!";
    return result.c_str(); // returns a pointer into a local that's about to be destroyed
}
#include <vector>
#include <iostream>

int main()
{
    std::vector<int> v = {10, 20, 30};

    for (auto it = v.begin(); it != v.end(); ++it)
    {
        if (*it == 20)
            v.erase(it); // erasing invalidates `it`, then the loop still does ++it on it
    }

    for (int n : v)
        std::cout << n << ' ';
}