"Undefined behavior" sounds like jargon for "unpredictable". It is stronger than that. When the C standard says a construct has undefined behavior, it means the standard imposes no requirements whatsoever on what the program does - not on the value, not on the statement, not on the program.
That is the part that surprises people: undefined behavior is not confined to the offending line. A compiler is allowed to assume it never happens, and to rewrite surrounding code on that assumption. The result can be a program in which a check you clearly wrote does not exist in the binary.
The Contract Model
Think of the standard as a contract between you and the compiler. You promise not to do certain things; in exchange, the compiler promises your program means what it says.
Do not index outside an array. Do not overflow a signed integer. Do not read an uninitialized value. Do not use a pointer after freeing it. Do not modify the same object twice in one expression without a sequence point between.
Break a clause and the deal is off - for the whole program, not just that line. There is no "reasonable fallback" and no requirement to crash.
Three related terms are worth separating:
- Undefined behavior - anything may happen. Out-of-bounds access, signed overflow, use after free.
- Unspecified behavior - one of several valid outcomes, and the compiler need not tell you which. The order in which function arguments are evaluated, for example.
- Implementation-defined behavior - the implementation picks, and must document its choice. Whether
charis signed, how large anintis.
Only the first is dangerous in the "the optimizer deleted my code" sense.
The Big Sources
Signed Integer Overflow
Unsigned arithmetic wraps, and the standard says so. Signed arithmetic does not - exceeding the range is undefined.
The check if (a > INT_MAX - b) runs entirely inside the valid range, which is what makes it a correct overflow test. Writing if (a + b < 0) performs the overflow first and then asks about the result - and the compiler, entitled to assume the overflow never happened, may remove the check.
Out-of-Bounds Access
Reading or writing outside an array is undefined, whether or not it crashes:
int arr[5] = {1, 2, 3, 4, 5};
int x = arr[5]; /* UB: index 5 does not exist */
arr[-1] = 0; /* UB */
int *p = arr + 10; /* UB even to compute this pointer */
Note that last line: forming a pointer more than one past the end is undefined even if you never dereference it. The standard permits arr + 5 (one past the end, for loop termination) but not arr + 6.
Small overruns are the dangerous ones. They usually do not segfault; they quietly overwrite a neighbouring variable, and the wrong answer shows up somewhere unrelated.
Uninitialized Reads
int x;
printf("%d\n", x); /* UB: reading an indeterminate value */
int *p;
*p = 42; /* UB: dereferencing an indeterminate pointer */
It is tempting to think "it just holds garbage", but that is not what the standard says, and compilers exploit the difference. GCC has been known to conclude that a variable read before assignment can hold any value it likes - including whatever makes a branch fold away.
Dangling Pointers
int *p = malloc(sizeof *p);
free(p);
*p = 42; /* UB: use after free */
free(p); /* UB: double free */
int *q;
{
int local = 10;
q = &local;
}
printf("%d\n", *q); /* UB: the object's lifetime ended */
The runtime consequences are covered in segmentation fault; the point here is that the crash is the lucky outcome.
The Wrong printf Specifier
printf("%d\n", 3.14); /* UB: %d with a double */
printf("%s\n", 42); /* UB: %s with an int - usually crashes */
printf("%d %d\n", 1); /* UB: fewer arguments than specifiers */
long n = 5;
printf("%d\n", n); /* UB on systems where long is wider than int */
printf is variadic: it reads arguments according to the format string and cannot check them. A mismatch makes it read the wrong number of bytes from the wrong place. Compile with -Wall and the compiler checks the format string for you - this is one of the highest-value warnings in the set.
Modifying an Object Twice in One Expression
int i = 0;
i = i++ + ++i; /* UB */
arr[i] = i++; /* UB */
printf("%d %d\n", i++, i); /* UB */
These are undefined, not merely "compiler-dependent". Textbook puzzles asking what i = i++ + ++i evaluates to have no correct answer.
Strict Aliasing
Accessing an object through a pointer of an incompatible type is undefined, and this one surprises experienced programmers:
float f = 1.0f;
int *p = (int *)&f;
printf("%d\n", *p); /* UB: reading a float through an int * */
The compiler assumes an int * and a float * never point at the same memory, and reorders accordingly. The defined way to reinterpret bytes is memcpy (which optimizes to the same instructions) or a union:
A char * is the exception - you may always inspect any object's bytes through unsigned char *.
Why "It Works on My Machine" Proves Nothing
Undefined behavior often appears to work, and that is what makes it dangerous. The program runs correctly through development and testing, then breaks when something entirely unrelated changes:
- A new compiler version with a smarter optimizer.
- Switching from
-O0to-O2for the release build. - Adding an unrelated function, shifting the stack layout so an overrun now lands on something that matters.
- A different machine, a different libc, a different operating system.
The appearance of working is not evidence of correctness, because the standard never promised anything. It is a bug in a dormant state, and the trigger is usually the release build.
How the Optimizer Exploits It
Here is the example that usually settles the argument. A programmer writes a null check:
void process(int *p) {
int value = *p; /* dereference */
if (p == NULL) { /* then check for null */
return;
}
printf("%d\n", value * 2);
}
The order is wrong - the check comes after the dereference - but surely the check still runs?
It does not have to. The compiler reasons: *p was dereferenced, therefore p cannot be NULL (dereferencing NULL is undefined, so in any program with defined behavior it is not NULL), therefore p == NULL is always false, therefore the entire if body is dead code and can be deleted.
The compiled function has no null check in it at all. A real instance of this pattern in the Linux kernel became CVE-2009-1897, where GCC removed exactly such a check and turned a benign-looking ordering mistake into an exploitable vulnerability.
A second, smaller example:
/* An overflow check that does not work */
int safe_add(int a, int b) {
int sum = a + b;
if (sum < a) { /* "did it wrap?" */
return -1;
}
return sum;
}
For signed types the compiler may assume a + b did not overflow, in which case sum < a is only possible when b < 0. With that assumption the check tests something other than what the author meant, and with b >= 0 it may be optimized away completely. The version that works checks beforehand:
Both tests stay inside the representable range, so no overflow ever occurs and there is nothing for the optimizer to assume away. (GCC and clang also provide __builtin_add_overflow, which does this in one instruction.)
Detecting It
Static warnings first - they are free:
gcc -Wall -Wextra -Wpedantic program.c -o program
That catches format-string mismatches, some uninitialized reads, suspicious comparisons, and unreachable code.
Then the sanitizers, which instrument the program and report at the moment of the violation:
gcc -g -fsanitize=undefined program.c -o program
./program
program.c:8:15: runtime error: signed integer overflow:
2147483647 + 1 cannot be represented in type 'int'
Combine with AddressSanitizer for the memory half:
gcc -g -Wall -Wextra -fsanitize=address,undefined program.c -o program
Together they catch out-of-bounds accesses, use-after-free, double frees, signed overflow, invalid shifts, misaligned pointers, and null dereferences - each with a file, a line, and a stack trace. Roughly 2x slower, which is nothing during development.
valgrind ./program needs no recompilation and catches uninitialized reads and memory errors, though not arithmetic UB. clang --analyze and gcc -fanalyzer find some of it without running the program at all.
The practical rule: run your tests under the sanitizers in CI. UB that appears to work locally is exactly what they exist to expose.
Living With It
You cannot avoid undefined behavior by being careful - everyone writes it eventually. What works is making it loud:
- Build with
-Wall -Wextrafrom day one, and fix every warning. - Run tests under
-fsanitize=address,undefined. - Initialize every variable at declaration, and every pointer to
NULL. - Check array indices against the length, and loop with
i < n. - Check for overflow before the arithmetic, using
<limits.h>. - Set a pointer to
NULLafter freeing it. - Use
unsignedtypes where wraparound is the intended behavior - it is defined there. - Prefer
memcpyto pointer casts when reinterpreting bytes.
C's speed comes from the compiler being allowed to assume you kept the contract. That is a genuine trade, not a design flaw - and the tools above give you most of the safety back for a development-time cost of nothing.
For the runtime face of these rules, see segmentation fault; for the compile-time mistakes that come first, common errors.
Frequently Asked Questions
What is undefined behavior in C?
Code for which the C standard imposes no requirements at all. The compiler is free to produce anything: a crash, a wrong answer, code that appears to work, or code with the offending branch removed entirely. It is not "implementation-defined" or "random" - it is a contract you broke, and nothing is promised afterwards.
Why does C have undefined behavior instead of just defining everything?
Speed and portability. Requiring a bounds check on every array access would cost performance C was designed not to pay; defining signed overflow as wraparound would force extra instructions on hardware that traps instead. Leaving those cases undefined lets the compiler assume they never happen and optimize accordingly.
Is signed integer overflow undefined behavior in C?
Yes. INT_MAX + 1 is undefined - it is not guaranteed to wrap to INT_MIN. Unsigned overflow is different: it is fully defined and wraps modulo 2^N. That is why compilers may assume x + 1 > x is always true for a signed x, and delete an overflow check written that way.
How do I detect undefined behavior in my C program?
Compile with -Wall -Wextra to catch what the compiler can see statically, then run your tests with -fsanitize=address,undefined, which reports out-of-bounds accesses, use-after-free, signed overflow and more at the moment they happen, naming the file and line. valgrind catches a similar set without recompiling.