Undefined Behavior

Reviewed & published by Brayan K

By the end of this lesson you'll be able to recognise the most common sources of undefined behavior (UB) in C++, explain why "it works on my machine" is never proof of correctness, understand how the optimizer exploits UB, and catch it automatically with sanitizers — replacing each dangerous pattern with a safe alternative.

Part of the free C++ course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

💡 Real-World Analogy

UB is like an unsigned legal contract with a blank clause. The C++ standard is the contract; most operations have spelled-out rules. But a handful — reading past an array, dereferencing a freed pointer — fall into a clause that simply says "results are undefined". The compiler is then free to interpret that blank however makes your program fastest, including assuming you'd never trigger it. So when you do trigger it, there's no rule protecting you: the program might crash, might print nonsense, or might silently behave differently after the next recompile. UB isn't "a bug that crashes" — it's "a bug with no guaranteed behavior at all", which is far worse, because you can't even rely on it failing.

⚠️ The Common Sources of UB

UB SourceWhat triggers itCaught by
Out-of-bounds accessv[5] on a size-3 vectorASan
Use-after-freeuse *p after delete pASan
Dangling referencereturn ref to a localASan / -Wall
Signed overflowINT_MAX + 1UBSan
Uninitialized readint x; use x;-Wall / MSan
Null dereference*p when p is nullUBSan / ASan
Invalid downcastbad static_cast of base*UBSan (vptr)
Data race2 threads, 1 unguarded varTSan

UBSan = -fsanitize=undefined, ASan = -fsanitize=address, TSan = -fsanitize=thread, MSan = -fsanitize=memory. None of these slow your release build — you run them in debug and test builds.

1. Common UB — and the Safe Fix Beside It

The fastest way to learn UB is to see the dangerous line right next to its safe replacement. In the worked example below, every UB line is commented out (so the program still runs) and the safe version is live. Read each comment, run it, and notice that the fixes — .at(), a null check, initializing, a wider type — cost almost nothing.

#include <iostream>
#include <vector>
#include <climits>
using namespace std;

// Undefined Behavior (UB) = an operation the C++ standard leaves
// with NO rules. The compiler may crash, print garbage, or seem
// to "work" today and break after the next recompile.
// Below: each UB line is COMMENTED OUT, with the safe fix live.

int main() {
    // 1) Signed integer overflow -> UB
    // int x = INT_MAX; x = x + 1;   // UB: overflow is undefined
    long long big = (long long)INT_MAX + 1;   // SAFE: wider type
    cout << "Safe sum: " << big << endl;       // Safe sum: 2147483648

    // 2) Out-of-bounds access -> UB
    vector<int> v = {10, 20, 30};
    // cout << v[5];                  // UB: [] does NO bounds check
    try {
        cout << v.at(5) << endl;     // SAFE: .at() throws instead
    } catch (const out_of_range& e) {
        cout << "Caught: " << e.what() << endl; // Caught: ...out of range...
    }

    // 3) Null pointer dereference -> UB
    int* p = nullptr;
    // cout << *p;                    // UB: dereferencing null
    if (p) cout << *p; else cout << "p is null - skipped" << endl;

    // 4) Reading an uninitialized variable -> UB
    // int junk; cout << junk;        // UB: reads garbage
    int init = 0;                     // SAFE: always initialize
    cout << "init = " << init << endl;          // init = 0

    // 5) Shifting by >= the bit width -> UB
    // int bad = 1 << 32;             // UB if int is 32 bits
    unsigned long long ok = 1ULL << 32;         // SAFE: wide enough
    cout << "1ULL << 32 = " << ok << endl;      // 1ULL << 32 = 4294967296
    return 0;
}

// ✅ Expected output:
//    Safe sum: 2147483648
//    Caught: vector::_M_range_check: __n (which is 5) >= this->size() (which is 3)
//    p is null - skipped
//    init = 0
//    1ULL << 32 = 4294967296

Notice the pattern: the bounds-checked call v.at(5) throws instead of quietly corrupting memory, and a wider type holds a value that would overflow a 32-bit int. The unsafe versions might appear to work — that's exactly why UB is so treacherous.

2. How the Compiler Exploits UB

Here's the part that surprises everyone. The optimizer is allowed to assume your program never has UB. So if you dereference a pointer, it concludes the pointer can't be null — and may delete a null check you wrote afterwards, because (in its reasoning) that check could only matter in a case that already invoked UB. Your safety net silently disappears. The rule that saves you is simple: check before you act, never after.

#include <iostream>
using namespace std;

// The optimizer ASSUMES your program has no UB. So it reasons:
// "this branch can only run after UB, therefore it never runs,
//  therefore I can delete it." Your safety net vanishes silently.

int readFirst(int* p) {
    int value = *p;          // (A) dereference -> compiler now ASSUMES p != null
    if (p == nullptr) {      // (B) compiler can DELETE this check —
        return -1;           //     it "proved" p is non-null at (A)
    }
    return value;
}

// SAFE version: check BEFORE you dereference, so there is no
// UB for the optimizer to reason backwards from.
int readFirstSafe(int* p) {
    if (p == nullptr) return -1;  // check FIRST -> branch is real
    return *p;                    // only reached when p is valid
}

int main() {
    int n = 42;
    cout << "readFirst(&n)      = " << readFirst(&n) << endl;       // 42
    cout << "readFirstSafe(&n)  = " << readFirstSafe(&n) << endl;   // 42
    cout << "readFirstSafe(0)   = " << readFirstSafe(nullptr) << endl; // -1
    // readFirst(nullptr) is UB — the (B) check may be GONE, so it
    // would NOT return -1. Never rely on a check placed after UB.
    return 0;
}

// ✅ Expected output:
//    readFirst(&n)      = 42
//    readFirstSafe(&n)  = 42
//    readFirstSafe(0)   = -1

Now you try. The program below contains UB. Fill in the two blanks marked ___ to make every access safe, using the hints in the comments.

#include <iostream>
#include <vector>
using namespace std;

int main() {
    // 🎯 YOUR TURN — this code has UB. Make it safe.
    vector<int> scores = {90, 80, 70};

    // 1) Read index 5 SAFELY so a bad index throws instead of UB.
    //    scores[5] would be undefined behavior — use the checked call.
    try {
        int s = scores.___(5);   // 👉 the bounds-checked member: at
        cout << s << endl;
    } catch (const out_of_range& e) {
        cout << "Out of range!" << endl;
    }

    // 2) A pointer that might be null — guard before dereferencing.
    int* ptr = nullptr;
    if (___) {                   // 👉 the condition that is true only when ptr is valid
        cout << *ptr << endl;
    } else {
        cout << "ptr is null" << endl;
    }

    // ✅ Expected output:
    //    Out of range!
    //    ptr is null
    return 0;
}

3. Lifetime UB: Dangling References & Use-After-Free

A dangling reference points at memory whose owner has already been destroyed; use-after-free touches memory you've deleted. Both are UB and both are among the hardest bugs to find, because the freed memory often still holds the old value — until something else reuses it. The cure is ownership: return by value instead of returning references to locals, and let std::unique_ptr own heap memory so it can never outlive its owner.

#include <iostream>
#include <memory>
#include <string>
using namespace std;

// Lifetime UB: touching memory AFTER its owner is gone.
// Two classics — a dangling reference and use-after-free.

// BAD: returns a reference to a local that dies at the brace.
// const string& makeName() {
//     string name = "Ada";   // local
//     return name;           // UB: 'name' is destroyed on return
// }

// SAFE: return BY VALUE — the caller gets its own copy.
string makeNameSafe() {
    string name = "Ada";
    return name;              // value is moved/copied out — always valid
}

int main() {
    cout << makeNameSafe() << endl;   // Ada

    // BAD: use-after-free with a raw pointer
    // int* raw = new int(7);
    // delete raw;            // memory freed
    // cout << *raw;          // UB: use-after-free (may print 7... or crash)

    // SAFE: let a smart pointer own the lifetime for you
    auto owned = make_unique<int>(7);
    cout << "owned = " << *owned << endl;   // owned = 7
    // 'owned' frees its int automatically at the end of main — no
    // dangling pointer can outlive it, so use-after-free is impossible.
    return 0;
}

// ✅ Expected output:
//    Ada
//    owned = 7

4. Signed Overflow & Catching UB with Sanitizers

Signed integer overflow — like INT_MAX + 1 — is UB (unsigned overflow, by contrast, is defined and wraps around). You prevent it by checking before you add, widening the type, or using an unsigned counter. But you can't eyeball UB reliably, so the real workhorses are sanitizers: build with -fsanitize=undefined (UBSan) and -fsanitize=address (ASan) plus -Wall -Wextra, run your tests, and the tools print the exact file and line where UB occurs.

#include <iostream>
#include <climits>
using namespace std;

int main() {
    // 🎯 YOUR TURN — signed overflow is UB; prevent it with a check.
    int a = INT_MAX;
    int b = 1;

    // 1) Only add when it is SAFE. Adding b would overflow if
    //    a is already greater than INT_MAX - b.
    if (a > INT_MAX - ___) {     // 👉 the value being added: b
        cout << "Would overflow - blocked!" << endl;
    } else {
        cout << a + b << endl;
    }

    // ✅ Expected output:
    //    Would overflow - blocked!
    return 0;
}

This last worked example isn't something to run for output — it's the exact compile command you'll use to catch UB in your own projects. Read the flags and the sample sanitizer reports.

#include <iostream>
using namespace std;

// You cannot SEE UB by reading code alone — tools catch it.
// Build with sanitizers and full warnings, then run your tests.

int main() {
    // Compile this file like so, then run it:
    //
    //   g++ -std=c++20 -Wall -Wextra -fsanitize=undefined,address -g main.cpp
    //
    //   -Wall -Wextra          turn on the high-value warnings
    //   -fsanitize=undefined   UBSan: overflow, bad shifts, bad casts
    //   -fsanitize=address     ASan: out-of-bounds, use-after-free, leaks
    //
    // If the program had UB, the sanitizer prints a report like:
    //   runtime error: signed integer overflow: 2147483647 + 1 ...
    //   ERROR: AddressSanitizer: heap-use-after-free on address 0x...

    cout << "Build with -Wall -Wextra -fsanitize=undefined,address" << endl;
    cout << "Then run your tests — the sanitizer reports UB at runtime." << endl;
    return 0;
}

// ✅ Expected output:
//    Build with -Wall -Wextra -fsanitize=undefined,address
//    Then run your tests — the sanitizer reports UB at runtime.

🔎 Deep Dive: invalid downcasts & data races

Two more UB sources catch intermediate programmers. An invalid downcast happens when you static_cast a base pointer to a derived type the object isn't — the compiler trusts you, and using the result is UB. Use dynamic_cast (which returns nullptr on a bad cast) when you're not certain of the runtime type.

A data race is two threads accessing the same variable at the same time with at least one writing, and no synchronisation — also UB. Protect shared state with a std::mutex or make it std::atomic. The thread sanitizer (-fsanitize=thread) finds these.

Base* b = new Base();
Derived* d = static_cast<Derived*>(b); // ❌ UB: b is not a Derived
Derived* safe = dynamic_cast<Derived*>(b); // ✅ safe -> nullptr here

int counter = 0;            // shared by two threads:
// thread A: counter++;     // ❌ data race -> UB
// thread B: counter++;
std::atomic<int> ok{0};     // ✅ atomic, or guard with a std::mutex

Pro Tips

Common Errors (and the fix)

📋 Quick Reference: UB → sanitizer / fix

UB SourceCatch it withSafe fix
Out-of-bounds-fsanitize=address.at() / std::span
Use-after-free-fsanitize=addressunique_ptr
Dangling ref-Wall / ASanreturn by value
Signed overflow-fsanitize=undefinedcheck / wider type
Uninitialized read-Wall / MSanint x{};
Null deref-fsanitize=undefinedif (p) before *p
Invalid downcast-fsanitize=undefineddynamic_cast
Data race-fsanitize=threadmutex / atomic

Mini-Challenge: Safe Lookup

No blanks this time — just a brief and an outline. Write a lookup that never invokes UB no matter what index it's given. Build it, run it, and check your output against the examples in the comments.

#include <iostream>
#include <vector>
using namespace std;

int main() {
    // 🎯 MINI-CHALLENGE: Safe lookup
    // 1. Make a vector<int> called data with {5, 10, 15}.
    // 2. Ask for an index with: int i; cin >> i;  (or just set int i = 5;)
    // 3. SAFELY return data at index i:
    //      - if i is in range, print the value
    //      - otherwise print "Invalid index"  (no UB, no crash)
    //    Hint: guard with  if (i >= 0 && i < (int)data.size())
    //          OR use a try/catch around data.at(i).
    //
    // ✅ Example (i = 5): Invalid index
    // ✅ Example (i = 1): 10

    // your code here
    return 0;
}

🎉 Lesson Complete

Practice quiz

What does undefined behavior (UB) mean in C++?

  • A guaranteed crash
  • A compiler warning
  • An operation the standard places no requirements on
  • A slow operation

Answer: An operation the standard places no requirements on. UB is an operation the standard leaves with no rules. The program may crash, print garbage, or appear to work — which is what makes it dangerous.

If a program with UB produces the right output today, what can you conclude?

  • Nothing — it may fail after a recompile or on another platform
  • The code is correct and UB-free
  • The UB has been fixed
  • The compiler removed the UB

Answer: Nothing — it may fail after a recompile or on another platform. UB can produce correct output by luck. 'It works' is never proof; only sanitizers and careful reasoning show code is UB-free.

Which of these is NOT undefined behavior?

  • Signed integer overflow (INT_MAX + 1)
  • Reading past the end of an array
  • Dereferencing a null pointer
  • Unsigned integer overflow (wraps modulo 2^N)

Answer: Unsigned integer overflow (wraps modulo 2^N). Unsigned overflow is fully defined — it wraps modulo 2^N. Only signed overflow is UB; the other listed operations are also UB.

Why can the optimizer delete a null check placed AFTER a pointer dereference?

  • Null checks are always redundant
  • It assumes UB never happens, so a prior deref 'proves' the pointer is non-null
  • Checks after dereferences are syntax errors
  • The compiler runs checks at link time

Answer: It assumes UB never happens, so a prior deref 'proves' the pointer is non-null. The optimizer assumes no UB. After *p it concludes p is non-null and may remove a later if (p == nullptr). Check before you dereference.

Which sanitizer catches out-of-bounds access and use-after-free?

  • ASan (-fsanitize=address)
  • UBSan (-fsanitize=undefined)
  • TSan (-fsanitize=thread)
  • -Wall only

Answer: ASan (-fsanitize=address). AddressSanitizer (ASan, -fsanitize=address) catches memory errors like out-of-bounds, use-after-free, and leaks.

Which sanitizer catches signed overflow, invalid shifts, and bad casts?

  • ASan (-fsanitize=address)
  • TSan (-fsanitize=thread)
  • UBSan (-fsanitize=undefined)
  • MSan (-fsanitize=memory)

Answer: UBSan (-fsanitize=undefined). UndefinedBehaviorSanitizer (UBSan, -fsanitize=undefined) catches language-level UB such as signed overflow, bad shifts, and bad casts.

What is the safe fix for returning a reference to a local variable?

  • Mark the local static
  • Return by value instead
  • Use a const reference
  • Add a virtual destructor

Answer: Return by value instead. Returning a reference to a local that dies at the closing brace is a dangling reference (UB). Return by value so the caller gets its own copy.

Why does v.at(5) on a size-3 vector behave more safely than v[5]?

  • at() is faster
  • at() returns 0 on bad index
  • v[5] always crashes
  • at() does bounds checking and throws std::out_of_range

Answer: at() does bounds checking and throws std::out_of_range. operator[] does no bounds check, so v[5] is UB. at() checks the index and throws std::out_of_range instead of corrupting memory.

A data race (two threads, one unguarded shared variable, at least one writing) is what?

  • Always safe
  • Undefined behavior
  • Defined behavior
  • A compiler error

Answer: Undefined behavior. Unsynchronized concurrent access with at least one write is a data race, which is UB. Guard with a std::mutex or make it std::atomic.

How do you safely prevent signed overflow in a + b for ints?

  • Cast the result to unsigned
  • Wrap the add in try/catch
  • Check a > INT_MAX - b before adding (or widen the type)
  • Use [] instead of at()

Answer: Check a > INT_MAX - b before adding (or widen the type). Check before you add: if a > INT_MAX - b the addition would overflow. Alternatively use a wider type or an unsigned counter.

Continue this course

Frequently asked questions

What exactly is undefined behavior in C++?

Undefined behavior (UB) is any operation the C++ standard places no requirements on — like reading past the end of an array. When it happens, the compiler is allowed to do literally anything: crash, return wrong answers, or appear to work today and break after a recompile. It is not the same as a guaranteed crash; that is the danger.

If my program runs fine, can it still have UB?

Yes, and this is the trap that catches everyone. UB can produce the 'right' output on your machine, with your compiler, today, then fail on a different platform or after the optimizer changes. 'It works' is never proof that code is UB-free — only sanitizers and careful reasoning are.

Why does the compiler 'exploit' UB instead of warning me?

The standard says a program with UB has no defined meaning, so the optimizer is free to assume UB never happens. If you dereference a pointer, it assumes the pointer is non-null and may delete a later null check. This produces faster code for correct programs, but silently removes your safety nets in buggy ones.

What is the difference between UBSan and ASan?

UBSan (-fsanitize=undefined) catches language-level UB like signed overflow, invalid shifts, and bad casts. ASan (-fsanitize=address) catches memory errors like out-of-bounds access, use-after-free, and leaks. They complement each other — run both in your debug and test builds.

Is unsigned overflow also undefined behavior?

No. Unsigned integer overflow is fully defined — it wraps around modulo 2^N. Only signed integer overflow is UB. That is exactly why checked arithmetic, wider types, or unsigned counters are the safe fixes when you cannot guarantee a result fits.

Related lessons