C Undefined Behavior
Undefined behavior (UB) is the part of C that trips up even experienced programmers, because it doesn’t mean “the program will crash” or “the result is unpredictable” — it means the C standard places no requirement whatsoever on what happens next. A program that invokes undefined behavior might print the “right” answer, print garbage, corrupt memory, or be silently transformed by the compiler into something that does none of what the source code appears to say. This isn’t a flaw in C; it’s a deliberate trade-off that lets compilers generate very fast code by assuming your program never does anything undefined. This lesson explains what UB actually is, where it hides, how optimizing compilers exploit it, and the concrete habits that keep your code out of its reach.
Overview: What “Undefined” Really Means
The C standard defines several tiers of “the compiler doesn’t have to do exactly one specific thing,” and mixing them up is where most confusion starts.
| Term | Meaning | Example |
|---|---|---|
| Well-defined | The standard specifies exactly one outcome. | int x = 2 + 2; always yields 4. |
| Unspecified behavior | The standard allows two or more outcomes and does not require the compiler to document which one it picked. | The order in which function arguments are evaluated. |
| Implementation-defined behavior | Like unspecified behavior, but the implementation must document its choice. | Whether plain char is signed or unsigned. |
| Undefined behavior | No requirement at all — any result is conforming, including none. | Signed integer overflow, dereferencing a null pointer. |
The C standard describes execution in terms of an abstract machine: a hypothetical, idealized machine that produces your program’s observable behavior — screen output, file writes, volatile accesses — one defined step at a time. A conforming compiler only has to reproduce those observable effects; it never has to reproduce your source code’s control flow literally. As long as a program never triggers undefined behavior, the compiler must behave as if it faithfully executed the abstract machine. The moment execution crosses into UB, that guarantee disappears entirely, and the compiler is free to assume the offending path is unreachable. This is exactly why UB is dangerous: the compiler isn’t merely failing to catch your bug — it is permitted to build an optimized program around the assumption that the bug does not exist.
Older standards described this in terms of “sequence points” — specific points in a program (the end of a full expression, before a function call, and so on) where all side effects of earlier evaluations must be complete. C11 replaced that language with a more general “sequenced before” relation, but the practical rule survives: if two evaluations are not sequenced relative to each other, and at least one of them modifies an object while the other reads or modifies that same object, the behavior is undefined. Expressions like i++ + i++ or a[i] = i++ fall directly into this trap.
Categories of Undefined Behavior
UB isn’t one bug — it’s an umbrella over dozens of distinct rule violations. The ones you’ll meet most often fall into a handful of categories.
| Category | Typical trigger |
|---|---|
| Arithmetic | Signed integer overflow, division or modulo by zero, shifting by a negative amount or by ≥ the type’s bit width. |
| Memory access | Dereferencing a null, dangling, or uninitialized pointer; indexing an array out of bounds; use-after-free; double free. |
| Uninitialized values | Reading an automatic-storage variable before it has been assigned a value. |
| Aliasing | Accessing an object through an lvalue of an incompatible type (the “strict aliasing” rule). |
| Sequencing | Modifying a scalar object twice, or reading and modifying it, with no sequencing between the two. |
| Type mismatches | Mismatched printf/scanf format specifiers versus argument types; calling through an incompatible function pointer type. |
| Lifetime | Returning the address of a local variable and using it after the function has returned. |
Some of these crash immediately and obviously, like a null-pointer dereference on most platforms. Others — especially arithmetic and aliasing violations — routinely produce output that looks completely correct until you change the compiler, the optimization level, or the target architecture. Those are the ones worth studying carefully, because they are the ones that survive code review and testing.
Examples
Example 1: An unsequenced modification. This program increments the same variable twice inside one expression with no sequencing between the two increments.
#include <stdio.h>
int main(void)
{
int i = 1;
int result = i++ + i++;
printf("i = %d, result = %d\n", i, result);
return 0;
}
Output (a typical gcc build, unoptimized):
i = 3, result = 3
Nothing in the standard requires this exact answer. i++ + i++ reads and writes i twice without any sequencing between the operations, so the standard permits any result at all — a different compiler, a different version of the same compiler, or a different optimization level could legally produce 2, 4, or something else entirely. The value shown here is simply what one common build happens to do; relying on it would be a mistake. Compiling with -Wall surfaces a warning here (“operation on ‘i’ may be undefined”) — always read your warnings.
Example 2: Signed integer overflow in a bounds check. This function tries to detect whether adding 1 to x would overflow.
#include <stdio.h>
#include <limits.h>
int will_not_overflow(int x)
{
return x + 1 > x;
}
int main(void)
{
printf("will_not_overflow(5) = %d\n", will_not_overflow(5));
printf("will_not_overflow(INT_MAX) = %d\n", will_not_overflow(INT_MAX));
return 0;
}
Output (unoptimized build):
will_not_overflow(5) = 1
will_not_overflow(INT_MAX) = 0
At this optimization level the generated code simply computes x + 1 using ordinary two’s-complement wraparound, so when x is INT_MAX the addition wraps to INT_MIN, and INT_MIN > INT_MAX is false. That looks like a working overflow check — but it is built entirely on signed overflow, which is undefined behavior in C. As the next section shows, a more aggressive optimizer is free to reach the opposite conclusion for the exact same source code.
Example 3: Type punning that violates strict aliasing. This function reads and modifies a float‘s bit pattern through an int pointer.
#include <stdio.h>
float bit_flip(float f)
{
int *ip = (int *)&f;
*ip = *ip ^ 1;
return f;
}
int main(void)
{
float f = 1.0f;
printf("%.9f\n", bit_flip(f));
return 0;
}
Output (unoptimized build):
1.000000119
Flipping the lowest bit of 1.0f‘s IEEE-754 representation (0x3F800000) produces 0x3F800001, whose value is 1 + 2^-23 ≈ 1.000000119. That is exactly what happens here — but accessing a float object through an int * violates the strict aliasing rule, which says an object may only be accessed through an lvalue of a compatible type (with a few exceptions like char *). The compiler is entitled to assume int * and float * never point to the same memory. At higher optimization levels it may keep the return value f cached in a register from before the write through ip, and return the original 1.000000000 instead — a silently wrong result with no crash and no warning.
Under the Hood: How Compilers Exploit UB
Undefined behavior is not just “the compiler doesn’t check for it” — optimizers actively use the assumption that UB never happens to simplify code, sometimes to your detriment. Here is what typically happens, step by step, for the overflow check in Example 2 when compiled with aggressive optimization (for example, -O2):
- The optimizer analyzes the expression
x + 1 > xinsidewill_not_overflow. - Because signed overflow is undefined behavior, the compiler’s range analysis is allowed to assume
x + 1never overflows for any input the function is ever called with — the casex == INT_MAXis treated as a situation that, by the language contract, simply cannot occur. - Under that assumption,
x + 1is mathematically always exactly one greater thanx, sox + 1 > xis always true. - The optimizer folds the comparison to the constant
1and deletes the addition and comparison instructions entirely; the function effectively becomesreturn 1;no matter what argument is passed. - At runtime, when the function actually is called with
x == INT_MAX, it still returns1— the overflow check has been optimized away, with no crash, no warning, and no trace in the generated assembly of the check you wrote.
This is the general pattern behind almost every “it worked in debug builds but broke in release” UB story: a check or piece of logic that only makes sense if UB occurs is precisely the thing the optimizer is licensed to erase.
Common Mistakes
Mistake 1: An off-by-one loop bound that writes out of bounds.
#include <stdio.h>
int main(void)
{
int arr[5];
for (int i = 0; i <= 5; i++) {
arr[i] = i * i;
}
printf("%d\n", arr[4]);
return 0;
}
The loop condition uses <= instead of <, so on the last iteration it writes to arr[5], one element past the end of a 5-element array. That write is undefined behavior: it may silently corrupt an unrelated stack variable, appear to do nothing, or crash, depending on the compiler, flags, and surrounding stack layout. The fix is simply to use the correct bound:
#include <stdio.h>
int main(void)
{
int arr[5];
for (int i = 0; i < 5; i++) {
arr[i] = i * i;
}
printf("%d\n", arr[4]);
return 0;
}
This version only ever touches indices 0 through 4 and reliably prints 16.
Mistake 2: Counting down with an unsigned index.
#include <stdio.h>
int main(void)
{
int arr[5] = {10, 20, 30, 40, 50};
for (size_t i = 4; i >= 0; i--) {
printf("%d\n", arr[i]);
}
return 0;
}
size_t is unsigned, so i >= 0 is always true regardless of i‘s value — the loop condition can never stop the loop. Once i reaches 0 and is decremented again, it wraps around (unsigned wraparound is well-defined) to SIZE_MAX, and the loop keeps calling arr[i] with enormous indices — each one an out-of-bounds access, which is undefined behavior, typically ending in a crash. The underlying mistake is using an unsigned type to count down through zero. A safe idiom tests the value before the decrement takes effect:
#include <stdio.h>
int main(void)
{
int arr[5] = {10, 20, 30, 40, 50};
for (size_t i = 5; i-- > 0; ) {
printf("%d\n", arr[i]);
}
return 0;
}
Here i-- > 0 compares the current value of i to 0 and only afterward decrements it, so the loop body runs for i equal to 4, 3, 2, 1, and 0 and then stops cleanly, printing 50, 40, 30, 20, 10.
Best Practices
- Always compile with
-Wall -Wextra(or your compiler’s equivalent) — most classic UB triggers a warning. - Use sanitizers during development:
-fsanitize=undefinedand-fsanitize=addresscatch huge classes of UB at runtime that no static warning will show. - Never rely on signed integer overflow wrapping; check for overflow before the operation (for example,
if (x > INT_MAX - 1)) or use unsigned arithmetic deliberately. - Initialize every variable at the point of declaration, even if you expect to overwrite it shortly after.
- Never index an array or dereference a pointer outside its valid range, including one-past-the-end dereferences (indexing is fine, dereferencing is not).
- Avoid type punning through incompatible pointer types; use
memcpyto reinterpret bytes, which is explicitly permitted and doesn’t rely on aliasing. - Match
printf/scanfformat specifiers exactly to argument types, and enable compiler format-checking (-Wformat). - Treat “it worked when I ran it” as meaningless evidence for UB — absence of a visible failure is not proof of correctness.
- Run static analyzers (
-fanalyzer,cppcheck,clang-tidy) periodically, not just your compiler’s default warnings.
Practice Exercises
Exercise 1. The function int sum_is_safe(int a, int b) { return a + b >= a; } is meant to detect whether adding two positive ints overflows, but it invokes the exact same undefined behavior discussed in this lesson. Rewrite it so it detects overflow without ever computing an out-of-range value (hint: rearrange the comparison so no addition of the two arguments happens before the check, or promote to a wider type such as long long if it is guaranteed to be wider).
Exercise 2. For each of the following, decide whether it is undefined behavior, unspecified behavior, or well-defined, and explain why: a[i] = i++;, 1 / 0 for integer operands, x << 32 where x is a 32-bit int, and printf("%d", (unsigned)1);.
Exercise 3. Take the uninitialized-read pitfall — a program that declares int x; and prints it without assigning a value first — and compile it with -fsanitize=undefined -fsanitize=address. Run it and note what the sanitizer reports; then explain in your own words why a debugger or a plain -Wall build might not have caught the same bug.
Summary
- Undefined behavior means the C standard imposes no requirement at all on what a program does — not a specific crash, not a specific wrong answer, literally anything.
- UB is distinct from unspecified behavior (one of several allowed outcomes) and implementation-defined behavior (an allowed outcome the compiler must document).
- Common sources include signed integer overflow, out-of-bounds memory access, uninitialized reads, strict-aliasing violations, and unsequenced modifications like
i++ + i++. - Optimizing compilers are entitled to assume your program never triggers UB, and will delete or simplify code based on that assumption — this is how a working-looking overflow check can silently stop working under
-O2. - Code that exhibits UB can appear correct for years and then break the moment the compiler, flags, or target platform changes.
- Warnings (
-Wall -Wextra) and sanitizers (-fsanitize=undefined,address) catch most real-world UB long before it reaches production — use them routinely, not just when debugging. - When in doubt, prefer well-defined idioms (unsigned wraparound instead of signed overflow,
memcpyinstead of pointer-cast type punning, explicit bounds checks) over code that merely happens to work today.
