C Undefined Behavior and Common Pitfalls
C provides direct access to memory and hardware-oriented operations, but this flexibility comes with strict rules. When a program violates certain rules of the C language, its behavior may become undefined.
Undefined behavior, commonly abbreviated as UB, means that the C standard imposes no requirements on what happens. The program might appear to work, produce unexpected results, crash, or behave differently after a compiler optimization.
What Is Undefined Behavior?
Undefined behavior occurs when a C program performs an operation for which the language standard provides no defined requirements.
int a = 10;
int b = 0;
int result = a / b;
Dividing an integer by zero has undefined behavior. A programmer cannot rely on a particular result or error mechanism.
Undefined Behavior vs Implementation-Defined Behavior
Undefined behavior should be distinguished from implementation-defined behavior. Implementation-defined behavior is behavior that the implementation chooses and documents.
| Category | Meaning |
|---|---|
| Defined behavior | The C standard specifies what happens |
| Implementation-defined | The implementation chooses and documents the behavior |
| Unspecified | The implementation may choose among multiple permitted behaviors |
| Undefined behavior | The standard imposes no requirements |
Why Undefined Behavior Is Dangerous
A common misconception is that undefined behavior simply means the program will crash. That is not true. The compiler is allowed to assume that a valid C program does not perform operations that invoke undefined behavior.
As a result, compiler optimizations can transform code in ways that are surprising when the source program already violates the language rules.
Out-of-Bounds Array Access
Accessing an element outside the bounds of an array is one of the most common sources of undefined behavior.
int values[3] = {10, 20, 30};
printf("%d\n", values[3]);
Valid indices are 0, 1, and 2. Accessing values[3] is outside the array.
Writing Beyond an Array
int values[3];
for (int i = 0; i <= 3; ++i) {
values[i] = i;
}
The loop writes four elements into an array that contains only three. The condition should normally be i < 3.
Pointer Arithmetic Outside an Object
Pointer arithmetic is constrained by the object to which a pointer refers. A pointer can generally point one element past an array for comparison or iteration, but that one-past pointer must not be dereferenced.
int values[3] = {1, 2, 3};
int *end = values + 3;
printf("%d\n", *end);
values + 3 is a valid one-past pointer, but dereferencing it is not valid.
Use-After-Free
Accessing dynamically allocated memory after it has been released is undefined behavior.
int *p = malloc(sizeof *p);
if (p != NULL) {
*p = 42;
free(p);
printf("%d\n", *p);
}
After free(p), the object that p pointed to no longer exists. The pointer value should not be dereferenced.
Double Free
int *p = malloc(sizeof *p);
free(p);
free(p);
Calling free twice on the same allocation without an intervening valid allocation results in undefined behavior.
Dangling Pointers
A dangling pointer is a pointer that refers to an object whose lifetime has ended.
int *get_value(void)
{
int value = 42;
return &value;
}
The local variable value ceases to exist when get_value returns. Returning its address produces a pointer that cannot safely be dereferenced.
Uninitialized Automatic Variables
Reading an uninitialized automatic object can produce an indeterminate value, and in some situations evaluating such a value can itself result in undefined behavior.
int value;
printf("%d\n", value);
Initialize automatic variables before using their values.
int value = 0;
printf("%d\n", value);
Null Pointer Dereferencing
int *p = NULL;
printf("%d\n", *p);
A null pointer does not point to a valid object. Dereferencing it is undefined behavior.
Returning the Address of a Local Variable
char *make_message(void)
{
char message[] = "hello";
return message;
}
The array message has automatic storage duration and ceases to exist when the function returns. Returning its address creates a dangling pointer.
Returning a Pointer to Static Storage
If persistent storage is appropriate, an object with static storage duration can remain alive after the function returns.
const char *make_message(void)
{
static const char message[] = "hello";
return message;
}
This is valid, but the returned storage is shared between calls and should not be treated like newly allocated memory.
String Literal Modification
String literals are not modifiable arrays. Attempting to modify one through a pointer invokes undefined behavior.
char *text = "hello";
text[0] = 'H';
Use an array when a modifiable string is required.
char text[] = "hello";
text[0] = 'H';
Buffer Overflows
Writing more data into an object than it can hold can corrupt adjacent memory and may cause undefined behavior.
char buffer[8];
strcpy(buffer, "This string is too long");
The destination does not have enough space for the source string and its terminating null character.
Incorrect String Termination
C strings require a terminating null character. Forgetting the terminator can cause functions such as strlen, printf with %s, and strcpy to read beyond the intended object.
char text[5] = {'H', 'e', 'l', 'l', 'o'};
printf("%s\n", text);
The array contains five characters but no terminating '\0'. It is therefore not a valid C string.
Integer Overflow
Signed integer overflow is undefined behavior in C.
#include <limits.h>
int x = INT_MAX;
x = x + 1;
The result cannot be represented by int, so the operation has undefined behavior.
Unsigned Integer Overflow
Unsigned integer arithmetic behaves differently. Unsigned arithmetic is performed modulo one more than the maximum representable value.
#include <limits.h>
unsigned int x = UINT_MAX;
x = x + 1;
printf("%u\n", x);
The result wraps according to the rules for unsigned arithmetic and is well-defined.
Signed and Unsigned Comparisons
Mixing signed and unsigned integers can produce surprising comparisons because the usual arithmetic conversions may convert a signed value to an unsigned type.
int a = -1;
unsigned int b = 1;
if (a < b) {
printf("less\n");
} else {
printf("not less\n");
}
The comparison is affected by the integer conversion rules. Avoid mixing signed and unsigned values without understanding the resulting types.
Division by Zero
int x = 10;
int y = 0;
int result = x / y;
Integer division by zero is undefined behavior. Check the divisor when zero is possible.
Remainder by Zero
int x = 10;
int y = 0;
int result = x % y;
The remainder operator also cannot be used with a zero divisor.
Shifting by an Invalid Amount
Shift operations have strict requirements. A shift count that is negative or greater than or equal to the width of the promoted left operand can result in undefined behavior.
unsigned int x = 1;
unsigned int y = 32;
unsigned int result = x << y;
If unsigned int has 32 value bits, shifting by 32 is invalid.
Shifting Signed Values
Signed shifts require particular care. For example, left-shifting a signed value can become undefined when the mathematical result cannot be represented.
int x = 1;
int result = x << 31;
Whether this is valid depends on the type's representation and the requirements of the specific operation. Prefer unsigned types for low-level bit manipulation when appropriate.
Operator Precedence Pitfalls
C has many operators with different precedence levels. Expressions that rely on subtle precedence rules can be difficult to read and easy to get wrong.
if (flags & MASK == 0) {
/* ... */
}
The expression is parsed according to C's precedence rules, which may not match the programmer's intention. Parentheses make the intended operation explicit.
if ((flags & MASK) == 0) {
/* ... */
}
Assignment Instead of Comparison
A single equals sign performs assignment rather than equality comparison.
if (value = 10) {
/* ... */
}
This assigns 10 to value and then evaluates the assigned value as the condition. Compilers with warnings enabled can often detect this mistake.
Sequence and Evaluation-Order Pitfalls
Do not modify an object multiple times in an expression when the evaluations are not properly sequenced.
int i = 1;
int result = i++ + i++;
The modifications of i are not safely sequenced relative to each other for this expression, making its behavior undefined.
Prefer separate statements when evaluation order could be unclear.
int i = 1;
int first = i++;
int second = i++;
int result = first + second;
Function Arguments and Evaluation Order
The order in which function arguments are evaluated is not generally something C code should rely on.
int i = 0;
printf("%d %d\n", i++, i++);
The two modifications of i are unsequenced relative to each other, so this expression has undefined behavior. Separate the operations.
Invalid Pointer Comparisons
Pointer comparisons have rules based on the objects involved. Relational comparisons such as < and > are meaningful for pointers into the same array object, including its one-past position.
Do not assume that relationally comparing arbitrary pointers from unrelated objects has a portable meaning.
Incorrect Pointer Types
Using an incompatible pointer type can lead to incorrect alignment, representation, or object-access assumptions.
double value = 3.14;
int *p = (int *)&value;
printf("%d\n", *p);
Casting a pointer does not automatically make accessing the object through the new type valid.
Strict Aliasing
C places restrictions on accessing an object through an incompatible lvalue type. Violating these rules can result in undefined behavior and can also allow optimizers to make assumptions that surprise programmers.
Character types have special rules for inspecting the object representation of other objects, but that does not mean arbitrary pointer casts are safe.
Misaligned Access
Some types require stricter alignment than others. Dereferencing a pointer that is not correctly aligned for its type can result in undefined behavior.
char buffer[8];
int *p = (int *)(buffer + 1);
*p = 42;
The address buffer + 1 may not satisfy the alignment requirement of int. Portable code should not assume arbitrary byte addresses are correctly aligned.
Reading Beyond an Object
int value = 42;
unsigned char *bytes = (unsigned char *)&value;
printf("%u\n", bytes[sizeof value]);
Valid byte indices are 0 through sizeof(value) - 1. Accessing bytes[sizeof value] goes beyond the object.
Incorrect sizeof Usage
sizeof behaves differently for arrays and pointers.
void print_size(int values[])
{
printf("%zu\n", sizeof(values));
}
Inside a function parameter declaration, int values[] is adjusted to a pointer type. sizeof(values) therefore gives the size of the pointer, not the size of the original array.
sizeof and Side Effects
The operand of sizeof is normally not evaluated when its type is not a variable length array type.
int i = 10;
size_t size = sizeof(i++);
printf("%d\n", i);
Here i++ is not evaluated, so i remains 10.
Incorrect malloc Size
Allocating the wrong number of bytes can cause memory corruption.
int *values = malloc(10);
if (values != NULL) {
for (int i = 0; i < 10; ++i) {
values[i] = i;
}
}
The allocation reserves only 10 bytes, not enough for ten int objects on typical systems. Prefer sizeof with the pointed-to type.
int *values = malloc(10 * sizeof *values);
Multiplication Overflow in Allocation Sizes
Even when sizeof is used, multiplying element counts can overflow size_t before malloc receives the result.
size_t bytes = count * sizeof *values;
int *values = malloc(bytes);
For untrusted or very large count values, check the multiplication before performing it.
#include <stdint.h>
if (count > SIZE_MAX / sizeof *values) {
/* allocation size would overflow */
}
Forgetting to Check malloc
int *values = malloc(100 * sizeof *values);
values[0] = 42;
If malloc returns NULL and the pointer is dereferenced, the program invokes undefined behavior. Check allocations when failure is possible.
Freeing Non-Allocated Memory
int value = 42;
free(&value);
free must only be given a pointer returned by an appropriate allocation function, or a null pointer. Passing the address of an automatic variable is invalid.
Freeing an Interior Pointer
int *values = malloc(10 * sizeof *values);
if (values != NULL) {
free(values + 1);
}
Only the pointer returned by the allocation function, or NULL, may be passed to free. An interior pointer cannot be passed to free.
Memory Leaks
A memory leak occurs when dynamically allocated memory is no longer reachable and cannot be released.
void process(void)
{
int *values = malloc(100 * sizeof *values);
if (values == NULL) {
return;
}
/* use values */
return;
}
The allocated memory is lost when the function returns. A matching free is required when ownership ends.
Incorrect realloc Usage
Assigning realloc directly to the only pointer can lose the original allocation if realloc fails.
values = realloc(values, new_size);
A safer pattern uses a temporary pointer.
int *tmp = realloc(values, new_size);
if (tmp != NULL) {
values = tmp;
} else {
/* values is still valid */
}
Lifetime of Compound Literals
Compound literals have storage duration determined by their context. Returning pointers to objects whose lifetime has ended can create dangling pointers.
Using a Pointer After Scope Ends
int *p;
{
int value = 10;
p = &value;
}
printf("%d\n", *p);
The lifetime of value ends when its enclosing block is left, so dereferencing p afterward is invalid.
Incorrect Format Specifiers
Passing a value of the wrong type to a variadic formatted function can cause undefined behavior.
size_t count = 100;
printf("%d\n", count);
Use the correct format specifier for size_t.
printf("%zu\n", count);
printf and Pointer Formats
When printing a pointer value with printf, use %p and pass a void pointer.
int value = 42;
printf("%p\n", (void *)&value);
Variadic Function Type Mismatches
The compiler cannot always check the types of arguments passed through an arbitrary variadic interface. Incorrect types can therefore be especially dangerous.
Incorrect scanf Usage
scanf requires pointers to objects of the appropriate type.
int value;
scanf("%d", value);
The correct call passes the address of value.
int value;
scanf("%d", &value);
scanf Buffer Overflow
String input with scanf must account for the destination buffer size.
char name[20];
scanf("%s", name);
The %s conversion has no inherent knowledge of the buffer capacity. A width can limit the number of characters read.
char name[20];
scanf("%19s", name);
Incorrect memcpy Sizes
memcpy copies exactly the number of bytes requested. The programmer is responsible for ensuring that both source and destination contain enough accessible bytes.
int source[10];
int destination[5];
memcpy(destination, source, sizeof source);
The destination is too small for the requested copy.
memcpy and Overlapping Memory
memcpy is not designed for overlapping source and destination regions. Use memmove when overlap is possible.
char text[] = "abcdef";
memcpy(text + 1, text, 5);
For overlapping ranges, use:
memmove(text + 1, text, 5);
Incorrect strncpy Assumptions
strncpy does not guarantee null termination when the source string length is at least the destination size.
char destination[5];
strncpy(destination, "hello", sizeof destination);
destination is not guaranteed to contain a terminating null character in this case.
Reading an Object with the Wrong Lifetime
An object's lifetime begins and ends according to its storage duration and the rules of the C language. Accessing an object after its lifetime has ended is invalid.
Data Races
When multiple threads access shared objects without appropriate synchronization and at least one access modifies the object, the program can have a data race. In C's memory model, a data race can result in undefined behavior.
Use appropriate synchronization mechanisms such as atomic operations or mutexes provided by the threading environment.
volatile Does Not Make Code Thread-Safe
The volatile qualifier is intended for objects whose values can change for reasons outside ordinary program flow, such as memory-mapped hardware registers or certain signal-related use cases. It does not provide general atomicity or inter-thread synchronization.
Incorrect Assumptions About Memory
C does not guarantee that all types have the same representation, alignment, byte order, or size across platforms.
- Do not assume sizeof(int) is always 4
- Do not assume pointers have the same size as int
- Do not assume a particular byte order
- Do not assume structures contain no padding
- Do not assume arbitrary addresses satisfy every type's alignment requirement
Structure Padding
Structures can contain padding bytes inserted to satisfy alignment requirements. Treating sizeof(struct) as the sum of member sizes can therefore be incorrect.
Using memcmp on Structures
Comparing structures with memcmp can be problematic because padding bytes may contain unspecified values and because equal logical values do not necessarily have identical object representations.
struct Point {
int x;
char flag;
};
if (memcmp(&a, &b, sizeof a) == 0) {
/* not necessarily a semantic equality test */
}
Compare structure members explicitly when semantic equality is required.
Pointer Provenance and Integer Conversions
Converting pointers to integers and back requires using an integer type capable of representing the pointer when the implementation provides one, such as uintptr_t. Even then, integer manipulation should not be treated as a general way to manufacture valid object pointers.
Incorrect Function Pointer Calls
Calling a function through a pointer whose type is incompatible with the actual function type can result in undefined behavior.
int add(int a, int b)
{
return a + b;
}
void (*wrong)(void) = (void (*)(void))add;
wrong();
Function pointers must be used with compatible function types.
Calling Through a NULL Function Pointer
void (*handler)(void) = NULL;
handler();
Calling through a null function pointer is invalid.
Recursion Without a Termination Condition
Unbounded recursion can exhaust the call stack and eventually lead to a runtime failure. Although stack exhaustion is not best described as ordinary C undefined behavior in every implementation, it is a serious portability and reliability problem.
void recurse(void)
{
recurse();
}
Ignoring Return Values
Many library and system functions report failure through return values. Ignoring those results can cause later operations to use invalid assumptions.
FILE *file = fopen("data.txt", "r");
char buffer[100];
fgets(buffer, sizeof buffer, file);
If fopen fails, file is NULL. The program should check it before using the stream.
Unchecked Array Lengths
Functions that receive pointers often cannot determine the size of the pointed-to array automatically. Pass the length explicitly when necessary.
void process(int *values, size_t count)
{
for (size_t i = 0; i < count; ++i) {
/* use values[i] */
}
}
Compiler Warnings
A strong warning configuration can catch many common mistakes before the program runs.
gcc -Wall -Wextra -Wpedantic -std=c17 -O2
Additional warnings can be enabled depending on the compiler and project requirements.
AddressSanitizer
AddressSanitizer, commonly called ASan, can detect many memory errors during testing, including out-of-bounds accesses and use-after-free.
gcc -fsanitize=address -g program.c -o program
UndefinedBehaviorSanitizer
UndefinedBehaviorSanitizer, commonly called UBSan, instruments programs to detect many categories of undefined behavior during execution.
gcc -fsanitize=undefined -g program.c -o program
Combining Sanitizers
gcc -fsanitize=address,undefined -g program.c -o program
Sanitizers are testing tools, not substitutes for correct code. They detect many problems but cannot prove that a program contains no undefined behavior.
Static Analysis
Static analysis tools examine source code without necessarily executing it. They can identify suspicious control flow, type mistakes, resource-management problems, and other defects.
Using static analysis alongside compiler warnings and runtime sanitizers provides broader coverage.
Defensive Programming
- Validate pointer arguments when NULL is a possible input
- Validate array lengths before accessing elements
- Check allocation results
- Check return values from important library calls
- Use sizeof instead of hard-coded type sizes
- Avoid unnecessary pointer casts
- Keep ownership rules explicit
- Initialize variables before use
- Use parentheses when operator precedence could be unclear
- Compile with strong warnings
- Run sanitizers during testing
Ownership Rules
Clear ownership rules reduce memory-management errors. Every allocation should have an identifiable owner responsible for eventually releasing it.
int *create_value(void)
{
int *p = malloc(sizeof *p);
if (p != NULL) {
*p = 42;
}
return p;
}
int main(void)
{
int *value = create_value();
if (value != NULL) {
printf("%d\n", *value);
free(value);
}
}
Prefer Simple Expressions
Complex expressions containing multiple side effects are harder to reason about and can accidentally violate C's sequencing rules.
array[index++] = value + index;
When an expression mixes modifications and reads of the same object, separate the operations into clear statements.
Do Not Rely on Accidental Behavior
A program that works with one compiler, optimization level, or machine may still contain undefined behavior. Testing alone does not establish that code is valid C.
Undefined Behavior and Optimization
Compilers use language guarantees to optimize programs. If the compiler can assume that undefined operations never occur in a valid program, it may eliminate branches or transformations that would otherwise appear necessary.
This is why undefined behavior can sometimes produce different results after changing optimization options without changing the source code.
Common Pitfalls Checklist
- Accessing arrays outside their bounds
- Dereferencing NULL or invalid pointers
- Using memory after free
- Freeing memory twice
- Freeing a pointer that was not returned by an allocator
- Returning pointers to expired local objects
- Modifying string literals
- Reading uninitialized objects
- Overflowing signed integers
- Using invalid shift counts
- Dividing by zero
- Violating object alignment requirements
- Breaking effective-type or aliasing rules
- Passing incorrect types to variadic functions
- Using incorrect printf or scanf format specifiers
- Using memcpy with overlapping regions
- Forgetting string null terminators
- Ignoring allocation or I/O failures
- Creating data races between threads
A Safer Coding Pattern
#include <stdio.h>
#include <stdlib.h>
#include <stddef.h>
int sum_values(const int *values, size_t count, int *result)
{
if (values == NULL || result == NULL) {
return 0;
}
int sum = 0;
for (size_t i = 0; i < count; ++i) {
sum += values[i];
}
*result = sum;
return 1;
}
int main(void)
{
int values[] = {10, 20, 30};
int result;
if (!sum_values(values, sizeof values / sizeof values[0], &result)) {
return EXIT_FAILURE;
}
printf("%d\n", result);
return EXIT_SUCCESS;
}
This example uses explicit lengths, checks pointer arguments, avoids manual allocation, and uses sizeof to determine the number of elements in the local array.
Debugging Undefined Behavior
- Compile with strong warnings.
- Reproduce the problem with a small test case.
- Run the program with AddressSanitizer and UndefinedBehaviorSanitizer.
- Use a debugger to inspect the failing operation.
- Check pointer lifetimes and ownership.
- Check every array index and allocation size.
- Review integer conversions and arithmetic.
- Inspect compiler diagnostics and optimization-related differences.
- Use static analysis where appropriate.
Best Practices
- Treat compiler warnings as errors for important projects where practical
- Prefer simple, explicit expressions
- Use bounds-aware APIs and explicit lengths
- Keep allocation and deallocation responsibilities clear
- Avoid unnecessary casts
- Use unsigned types deliberately rather than automatically
- Check arithmetic for overflow when sizes or inputs are untrusted
- Use sanitizers during development and testing
- Avoid relying on platform-specific behavior unless it is intentional
- Document assumptions about ownership, lifetime, alignment, and ABI
Quick Reference
| Pitfall | Safer Approach |
|---|---|
| Array out-of-bounds access | Validate indices and lengths |
| Use-after-free | Stop using pointers after their object lifetime ends |
| Double free | Define clear ownership and free each allocation once |
| Uninitialized variable | Initialize before reading |
| Signed overflow | Check ranges or use appropriate unsigned/wider types |
| Invalid shift | Validate the shift count |
| String overflow | Track destination capacity and termination |
| memcpy overlap | Use memmove |
| Wrong format specifier | Match the format to the argument type |
| Wrong allocation size | Use sizeof *pointer |
| realloc failure | Use a temporary pointer |
| Data race | Use atomics or appropriate synchronization |
Practice Exercises
- Find and fix an out-of-bounds array access
- Repair a use-after-free bug
- Rewrite code that incorrectly modifies a string literal
- Fix a signed integer overflow case
- Correct a program containing an invalid shift
- Find a missing null terminator in a string
- Replace unsafe memcpy usage with memmove where ranges overlap
- Fix incorrect printf format specifiers
- Rewrite unsafe realloc code using a temporary pointer
- Compile a buggy program with AddressSanitizer
- Compile a program with UndefinedBehaviorSanitizer
- Use compiler warnings to identify assignment-in-condition mistakes
- Write a function that safely processes an array using an explicit length
- Identify a potential data race in a multithreaded program
Conclusion
Undefined behavior is one of the most important concepts to understand when writing reliable C. Memory violations, invalid pointer use, signed overflow, incorrect string handling, invalid shifts, sequencing mistakes, and data races can all produce results that cannot safely be predicted.
The best defense is a combination of language knowledge, simple code, strong compiler warnings, explicit ownership and lifetime rules, careful bounds checking, and tools such as sanitizers and static analyzers. Avoiding undefined behavior is not merely about preventing crashes; it is essential for writing C programs whose behavior remains reliable across compilers, optimization levels, and platforms.