Undefined Behavior
Undefined behavior is not a run-time error and not a promise of a crash. It is a licence for the compiler to assume the program never does it — which turns a source-level mistake into a premise the optimizer reasons from.
What does "undefined behavior" actually license a compiler to do?
The program plus a set of assumptions the compiler is entitled to make about it: this addition does not overflow, this pointer is not null, this index is in range, these two pointers do not alias, this reference is live. In LLVM those assumptions are attached to the instructions as flags and attributes — nsw, nonnull, noalias, dereferenceable(N) — so undefined behavior in the source becomes an ordinary fact in the IR. That is the representation that makes this an optimization subject rather than only a safety one: UB is not an event that happens, it is a premise that is available.
Every phase is entitled to assume the program does not execute undefined behavior on any input it is actually given. This is the one precondition in the entire domain that the compiler does not have to establish: the language supplies it. Everything downstream follows — a value may be assumed in range because being out of range would be undefined, a pointer may be assumed non-null because dereferencing null would be undefined, a loop may be assumed to terminate because an infinite side-effect-free loop is undefined. The obligation the rule imposes on the compiler is only this: it must not *introduce* undefined behavior into a program that did not have it.
Key points
- Undefined behavior is a licence for the compiler to assume the program does not do it, not a category of run-time failure and not a promised diagnostic.
- The assumption propagates forward and backward through the function, which is why the visible symptom is usually far from the mistake.
- Undefined, unspecified and implementation-defined are three different categories, and only the first licenses that assumption.
- Each undefined construct exists because some optimization wanted the premise: no signed overflow buys loop reasoning, in-range indices buy bounds-check removal, no aliasing buys memory reordering.
- AtlasLang has no undefined behavior, so its optimizer gets no free assumptions — every pass it has is one that needs none.
- A language without undefined behavior must check what a language with it assumes, and give the checks back through analysis rather than through assumption.
- No diagnostic is required, so sanitizers, fuzzing and locally changing the language with flags like
-fwrapvare the practical tools.
A premise, not an event
The near-universal first understanding is that undefined behavior is a category of run-time failure — the program does something undefined, and then something bad happens. That model predicts a crash, or garbage output, or at worst a security hole at the point of the mistake. It is wrong in a way that matters, because it predicts the wrong *place*.
The specification does not say what happens when a program executes an undefined construct. It places no requirement on the implementation at all for such an execution. And a compiler is not an interpreter that discovers the construct at run time — it is a program that reasons about your source ahead of time. Given "this execution imposes no requirements on me", the useful inference is not "emit a crash here"; it is "assume this execution does not occur, and use that assumption everywhere it helps".
That inference runs both forward and backward. Forward: after *p, the pointer p is non-null for the rest of the block, so later checks on it are dead. Backward: if a path leads unavoidably to undefined behavior, the compiler may assume the path is never taken, and the *branch that guards it* becomes predictable. Both directions move the visible consequence away from the mistake, which is why UB bugs manifest as behavior changing in an unrelated function, or appearing only at a certain optimization level, or disappearing when a printf is added.
The category also does real work for the language. Every undefined construct in C is a check the implementation does not have to emit and an assumption the optimizer gets for free. Signed overflow being undefined is what lets a compiler treat i <= n in a loop as monotone and promote int induction variables to 64-bit registers without a wrap check. That is the bargain C made: performance and implementation freedom in exchange for putting the proof obligation on the programmer, with no diagnostic when the obligation is not met.
- Undefined behavior — no requirements at all. Signed overflow, out-of-bounds access, null dereference, data races, use-after-free, strict-aliasing violation.
- Unspecified behavior — several behaviors are allowed, the implementation picks one and need not document it or be consistent. The evaluation order of function arguments in C.
- Implementation-defined behavior — the implementation picks one and must document it. The size of
int, whethercharis signed. - Erroneous behavior — new in C++26: incorrect, diagnosable, with defined consequences. Reading an uninitialised variable moves out of the UB bucket into this one.
- The three older categories are routinely conflated in conversation, and only the first licenses the compiler to assume the program does not do it.
The catalogue, and what each entry buys the optimizer
C has a few hundred undefined constructs; the annex that lists them runs for pages. In practice a small number account for nearly all the surprises, and each one exists because some optimization wanted the assumption.
Read the middle column as the thing the compiler gets, not as a description of the mistake. That is the shift this lesson is asking for: the entry is not "out-of-bounds access crashes", it is "the compiler may assume every index is in range", and from that premise a bounds check becomes removable, a loop trip count becomes computable, and an array access becomes a candidate for vectorization.
| Undefined construct | What the compiler may then assume | What you observe when the assumption is false |
|---|---|---|
| Signed integer overflow | Arithmetic on int is monotone and never wraps; x + 1 > x is true; loops with int counters terminate. | A loop that should have ended runs forever, or a range check silently passes for a value that wrapped. |
| Out-of-bounds array access | Every index is within its object, so bounds checks are removable and trip counts are computable. | A neighbouring variable changes value, or a stack canary fires far from the offending write. |
| Null pointer dereference | Any pointer that has been dereferenced is non-null from that point on. | A null check written *after* a dereference disappears and a null pointer reaches code that assumed it could not. |
| Data race | Non-atomic memory can be cached in registers across a whole loop and written back once. | A flag another thread set is never observed, and the loop spins forever — but only in a release build. |
| Use after free | Storage that has been freed is not read, so stores to it are dead and loads may be reordered. | A read returns a value from an unrelated allocation, and the corruption surfaces in a different subsystem. |
| Strict-aliasing violation | Objects of unrelated types do not overlap, so a store through float* cannot affect a load through int*. | A value written through one pointer is not seen through the other, and the code works at -O0 and fails at -O2. |
AtlasLang has none of it, and that is a design choice with a bill
AtlasLang, the language behind every simulator in this domain, has no undefined behavior at all. Every operation on every input has a defined result. Division by zero faults deterministically rather than being undefined, which is why mayTrap exists and why the optimizer refuses to fold 10 / 0 into a constant — folding it would move a defined run-time fault into the build.
The consequence for the optimizer is exactly what the bargain predicts. AtlasLang gets no free assumptions. It cannot assume arithmetic does not wrap, because wrapping is defined. It cannot assume a division succeeds, because failure is defined. It cannot assume a call is pure, because there is no way to declare it and no interprocedural analysis to prove it, so hasEffect returns true for every call and dead-code elimination leaves them all in place. The passes that remain are the ones that need no assumptions: folding literals, propagating a single definition, reusing a dominating computation, deleting a value nothing reads.
A real language making this choice pays in emitted code rather than in missing passes. No undefined behavior means every operation that could go wrong must be checked: a bounds check on every index, an overflow check on every arithmetic operation or a defined wrap, a null check or a type system that removes the possibility. Those checks are the cost, and the entire discipline of [[bounds-check-elimination]] exists to give them back — an analysis proves the index is in range, and the check that was mandatory becomes removable. That is the interesting inversion: a language with UB assumes what a language without UB has to prove, and the second is more work for the compiler and less work for the programmer.
let n = 10; let d = 0; print(n / d);
▸%1 = const 10▸%2 = const 0▸%3 = int %1 / %2▸print %3
Read it asBoth operands are literals and the operation is arithmetic, so every condition for constant folding is met except one: mayTrap returns true for a division whose divisor is not a provably non-zero literal. The division therefore survives every pass and faults at run time, which is what the language defined it to do. A C compiler faced with 10 / 0 is in a completely different position — the behavior is undefined, so it may fold it to anything, warn, or delete the surrounding code entirely.
Finding it, since the compiler will not tell you
The defining practical property of undefined behavior is that no diagnostic is required and usually none is produced. A compiler that fully understood your mistake is still under no obligation to mention it, and several optimizations are implemented in ways that consume the assumption silently. Tooling is therefore not optional; it is the only mechanism that exists.
Sanitizers are the primary answer. UndefinedBehaviorSanitizer (-fsanitize=undefined) instruments arithmetic, shifts, casts, null checks and more, and reports at the moment the construct executes rather than at the moment its consequence appears. AddressSanitizer (-fsanitize=address) catches out-of-bounds and use-after-free with a shadow-memory scheme; ThreadSanitizer (-fsanitize=thread) catches data races by tracking happens-before. All three are run-time tools: they find undefined behavior on the inputs you actually ran, which is why they are paired with [[compiler-fuzzing]] to generate the inputs.
Changing the language locally is the other tool, and it is worth understanding as exactly that. -fwrapv makes signed overflow defined to wrap; -fno-strict-aliasing makes type-based aliasing assumptions unavailable; -ftrapv makes overflow trap. Each of these removes an assumption from the optimizer, which is why they cost performance, and each of them means you are no longer compiling standard C. The Linux kernel builds with -fno-strict-aliasing for precisely this reason and has done for decades.
Static analysis finds some of it before running: [[static-analysis]] tools, compiler warnings such as -Wall -Wextra, and the more aggressive -Wstrict-aliasing. These are incomplete by construction — deciding whether an arbitrary program has undefined behavior is undecidable — so they catch patterns rather than instances, and a clean run proves nothing.
How it works
The steps, in the order the compiler takes them.
- The language specification designates certain constructs as imposing no requirements on the implementation.
- The frontend translates the source and attaches the corresponding assumptions to the IR —
nswon signed arithmetic,nonnullanddereferenceableon pointer parameters, type-based alias metadata on loads and stores. - Analyses read those flags as facts: a range analysis treats
nswaddition as monotone, an alias analysis treats distinct type tags as disjoint. - Transformations then apply their ordinary preconditions, which are now satisfiable because the language supplied the missing fact.
- The assumption propagates: a pointer proved non-null at a dereference stays non-null for the rest of its dominated region, so later tests on it fold away.
- A path that unavoidably executes undefined behavior may be assumed unreachable, which lets the branch guarding it be simplified — moving the consequence upstream of the mistake.
- The compiler must not introduce undefined behavior where the source had none, which is why speculatively hoisting a load above a branch requires proving the load is safe to execute.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- Code works at
-O0and fails at-O2, and the failure is in a function that does not contain the mistake. - A test passes for two years and starts failing after a compiler upgrade, because a new pass began exploiting an assumption that was always available.
- A defensive null check silently vanishes from the binary, leaving the exact vulnerability the check was written to prevent.
- A loop with an
intcounter that should have terminated runs forever, because the compiler proved a wrapping counter impossible and promoted the induction variable. - A spin loop reading a non-atomic flag never observes the other thread's write, because the load was hoisted out of the loop under the no-data-race assumption.
- Adding a
printffor debugging makes the bug disappear, because the call blocked the transformation that was consuming the assumption. - A value written through one pointer type is invisible through another, and the resulting corruption is attributed to the network stack rather than to a cast.
When it helps
- Diagnosing "works at -O0, breaks at -O2", where undefined behavior is the first hypothesis and a sanitizer run is the fastest test of it.
- Reading compiler output that removed a check you wrote — the question is which construct earlier in the function supplied the premise.
- Choosing a language for a safety-critical component, where the size of the undefined-behavior surface is a direct input to the decision.
- Understanding why one compiler flag changes performance so much: the flag usually removes an assumption rather than turning off a pass.
When it hurts
- Treating it as a moral failing of C. The category bought decades of performance and portability across machines with wildly different arithmetic; the criticism that lands is about the absence of diagnostics, not about the existence of the concept.
- Assuming a sanitizer clean run means the program is free of it. Sanitizers are dynamic: they report what the inputs you ran actually executed, and undefined behavior on an untested path is exactly what ships.
What it costs
Every one of these is paid by something.
- Leaving a construct undefined buys the optimizer a free premise and pays the programmer an unchecked proof obligation, with no diagnostic and a consequence that appears somewhere other than the mistake.
- Defining everything buys predictability and pays either run-time checks on every operation or a type system strong enough to remove them — both are real costs, borne by the compiler and by the language's learning curve.
- Sanitizers buy precise reports at the point of the construct and pay two to twenty times the run time plus substantial memory, which is why they run in CI rather than in production.
-fwrapvand-fno-strict-aliasingbuy back predictability for existing code and pay measurable performance plus the fact that the program is no longer written in the standard language, so its meaning now depends on a build flag.
What else you could do
What a different compiler or language does instead, and when that is better.
- Define the behavior, as Java does: wrapping overflow, checked array bounds, throwing null dereference. The compiler loses several premises and the language becomes portable in a stronger sense.
- Make the dangerous operations unrepresentable, as Rust does with ownership and lifetimes, so aliasing and use-after-free are compile-time errors rather than undefined constructs —
[[ownership-types]]and[[lifetime-analysis]]. - Keep the behavior undefined but require a diagnostic, which is what C++26's erroneous behavior does for uninitialised reads: incorrect, diagnosable, with defined consequences rather than none.
- Trap instead of assuming:
-ftrapv, Rust's debug-profile overflow panics, and hardware memory tagging all convert an assumption into a check that fails loudly.
See it for yourself
The flag, dump or tool that shows you this directly.
- Explore it directly at
/compilers/ub-explorer, which shows a construct, the assumption it licenses, and the transformation that assumption enables. clang -fsanitize=undefined,addressand run your test suite. The reports name the construct and the source location, which is the information the compiler was never obliged to give you.- Compare
clang -O2output for a function with and without-fwrapvon a signed loop counter: the difference is one assumption, and it is usually visible as an extra sign-extension in the loop. clang -O2 -Rpass-analysis=...and GCC's-fopt-info-missedshow where an assumption was or was not available;-Wstrict-aliasing=2warns about the type-punning cases it can recognise.- The C standard's Annex J.2 lists the undefined behaviors explicitly. Reading a page of it is the fastest cure for the belief that there are only a handful.
Plausible wrong readings
Stated the way a confident engineer states them.
- "Undefined behavior means the program crashes." Nothing is required to happen, including a crash. The most common outcome is that it works, until a compiler upgrade or an inlining decision changes which assumptions were reachable.
- "The compiler should warn me about it." It is not obliged to, it frequently cannot — deciding it in general is undecidable — and the passes that consume the assumption usually cannot tell that they are.
- "It only matters for weird code." Signed overflow in a loop counter and reading one type through a pointer to another are both ordinary code, and both are undefined.
- "If it works in practice, it is fine." It works with the premises the current compiler happened to use. The next version, or the same version with different inlining, uses different ones.
- "Undefined behavior is what happens when you do something wrong." It is a property the language assigns to a construct, in advance. The program is undefined whether or not the construct ever executes on your inputs — and if it does execute, the whole execution is unconstrained, not just that statement.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Some things a program can do have no defined meaning in the language — reading past the end of an array, overflowing a signed integer, dereferencing null. The important part is what that means for the compiler: it is allowed to assume your program never does them. So instead of a crash at the mistake, you get code compiled on the assumption the mistake is impossible, and the visible damage turns up somewhere else entirely.
practical
When a program works at -O0 and fails at -O2, suspect this first and run a sanitizer before reading any assembly. When a check you wrote is missing from the binary, look earlier in the function for the operation that made the check redundant under the language's rules. If you must keep code that relies on wrapping or on type punning, say so with -fwrapv or -fno-strict-aliasing and understand that you have changed the language rather than tuned the optimizer. And put a sanitizer build in CI, because it is the only mechanism that reports at the construct instead of at the consequence.
advanced
The deepest issue is that undefined behavior is a property of an *execution*, not of a statement, and the standard's wording places no constraint on the parts of that execution that came before the offending construct. That is what licenses time-travel: a compiler may move a consequence earlier, because an execution that will unavoidably become undefined has no defined prefix to preserve. Proposals to narrow this — bounding the damage to the offending operation, or introducing erroneous behavior as C++26 does — are attempts to keep the optimization premises while making the failure mode local. They are hard precisely because the value of the premise comes from its unboundedness: an assumption that only holds locally cannot be propagated, and propagation is where nearly all the optimization was.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
unsafe blocks with documented obligations. C++26 introduces erroneous behavior as a distinct, diagnosable category, starting with uninitialised reads, which narrows the undefined surface for the first time in the language's history.nsw, nonnull, noalias, dereferenceable and TBAA metadata is LLVM's encoding; GCC carries equivalent information in its own IR and its -fstrict-overflow/-fstrict-aliasing machinery. Which assumptions a given compiler actually exploits changes between versions, which is why code that relied on an assumption not being used breaks on upgrade rather than on porting.-Wstrict-overflow and Clang's -Wtautological-pointer-compare are exceptions, and both are noisy enough that projects frequently disable them — so in practice the diagnostic path is sanitizers rather than warnings.If you were asked this in an interview
- Define undefined behavior without using the words "crash" or "error".
- Why does undefined behavior make programs fail at higher optimization levels rather than lower ones?
- Signed overflow is undefined and unsigned overflow is defined. What does the compiler get from the first that it does not get from the second?
Connections
- Programming Languages & Runtime Internals — What a managed runtime does instead of leaving behavior undefinedA JVM or CLR checks bounds, checks nulls and defines overflow at run time, so the assumptions described here are simply unavailable to it. The mechanisms it uses instead — trap handlers, implicit null checks via signal handling, deoptimization — are runtime machinery owned there, and they are the direct alternative to the bargain C made.