Undefined Behavior
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 examples you run in your browser, 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 Source
What triggers it
Caught by
Out-of-bounds access
v[5] on a size-3 vector
ASan
Use-after-free
use *p after delete p
Dangling reference
return ref to a local
ASan / -Wall
Signed overflow
INT_MAX + 1
UBSan
Uninitialized read
int x; use x;
-Wall / MSan
Null dereference
*p when p is null
UBSan / ASan
Invalid downcast
bad static_cast of base*
UBSan (vptr)
Data race
2 threads, 1 unguarded var
TSan
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.
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 .
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.
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 delete d. 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.
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.
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.
🔎 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.
Pro Tips
- 💡 "It works" proves nothing: UB can produce correct output today and fail after a recompile. Only sanitizers and reasoning prove a program is UB-free.
- 💡 Check before you act: guard a pointer before dereferencing — a check placed after UB can be optimized away.
- 💡 Prefer .at() while learning: it throws on a bad index instead of silently corrupting memory like [] .
- 💡 Own memory with smart pointers: std::unique_ptr and std::shared_ptr make use-after-free almost impossible.
- 💡 Make sanitizers your default debug build: -Wall -Wextra -fsanitize=undefined,address on every test run.
Common Errors (and the fix)
- "runtime error: signed integer overflow": UBSan caught a + b exceeding INT_MAX . Check a > INT_MAX - b first, or use a wider/unsigned type.
- "AddressSanitizer: heap-buffer-overflow": you indexed past the end with v[i] . Use v.at(i) or guard i < v.size() .
- "AddressSanitizer: heap-use-after-free": you used a pointer after delete . Let a std::unique_ptr own the memory so it can't be used past its lifetime.
- "warning: reference to local variable returned" (-Wall): you returned a reference to a local. Return by value instead.
- "runtime error: load of null pointer" (UBSan): you dereferenced a null pointer. Add if (p) before using *p .
📋 Quick Reference: UB → sanitizer / fix
Catch it with
Safe fix
Out-of-bounds
-fsanitize=address
.at() / std::span
unique_ptr
Dangling ref
-Wall / ASan
return by value
-fsanitize=undefined
check / wider type
int x{};
Null deref
if (p) before *p
dynamic_cast
-fsanitize=thread
mutex / atomic
Frequently Asked Questions
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.
🎉 Lesson Complete
- ✅ UB is an operation the standard leaves with no rules — worse than a guaranteed crash
- ✅ Common sources: out-of-bounds, use-after-free, dangling refs, signed overflow, uninitialized reads, null deref, bad downcasts, data races
- ✅ The optimizer assumes UB never happens and can delete checks placed after it — so check first
- ✅ Catch UB with -fsanitize=undefined (UBSan), -fsanitize=address (ASan), -fsanitize=thread (TSan), and -Wall -Wextra
- ✅ Safe fixes: .at() , smart pointers, return-by-value, checked arithmetic, dynamic_cast , mutex / atomic
- ✅ Next lesson: Inline Assembly — drop below C++ to talk to the CPU directly
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
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)
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
- Previous: Profiling & Optimization
- Next: Inline Assembly