Undefined Behavior, Common Pitfalls & Best Practices

C trusts the programmer more than almost any other mainstream language — which means it also punishes mistakes more severely, often silently. Recognizing undefined behavior and the classic pitfalls around it is what separates code that merely compiles from code that's actually correct.

C gives programmers a great deal of control over memory, types, pointers, and machine-level operations — control that also places responsibility on the programmer to follow the language's rules. Undefined behavior occurs when a program performs an operation the C standard imposes no requirements on; once it happens, nothing about the program's output, crash behavior, or even its behavior after a compiler upgrade can be reasoned about safely. This page works through what undefined behavior actually is, the long list of classic ways C programs trigger it, and the habits, tools, and design practices that keep a program on the safe side of the line.

What Is Undefined Behavior?

The C standard describes what a valid program must do for the operations it does define, but for certain operations it places no requirements on the result at all. That gap is undefined behavior: once a program crosses it, the standard simply has nothing to say about what happens next.

Valid indexes for a five-element array run from 0 to 4. Writing to a[5] reaches one element past the array's storage — the standard does not define what that write does.

The program might appear to work, silently overwrite an unrelated object, crash immediately, print garbage, or behave differently after a compiler upgrade or optimization level change — there is no single guaranteed outcome to expect.

int a[5];
a[5] = 10;   // index 5 is outside the valid range 0..4

UB Does Not Mean "It Will Crash"

A common misconception is that undefined behavior always announces itself with a crash. It often does not — that is precisely what makes it dangerous. The standard does not define the result, but an unlucky test run can still produce output that looks perfectly reasonable.

Division by zero here is undefined behavior. It may crash on one machine, print a bizarre value on another, or seem to work on this particular build — none of those outcomes make the code correct.

Passing on one test run is not evidence of correctness once undefined behavior is present — the same source can behave differently the moment the compiler, optimization flags, or hardware change.

int x = 0;
int y = x / 0;
printf("%d\n", y);

Why Undefined Behavior Exists

C was designed to compile efficiently across an enormous range of machines, so the standard deliberately does not pin down every machine-level situation — out-of-bounds access, invalid pointer dereferences, signed overflow, and using an object outside its lifetime are all left undefined rather than forced to a single, possibly expensive, behavior on every platform.

Leaving these cases undefined also gives compilers real optimization freedom: the compiler is allowed to assume that a valid program never triggers undefined behavior, and it can generate faster code based on that assumption. The cost of that freedom is that a program that does trigger UB has stepped outside the assumptions the compiler was allowed to make.

Undefined vs Implementation-Defined vs Unspecified

C actually has three different categories for situations the standard does not fully pin down, and it matters which one applies.

Undefined behavior means no requirements at all — int x = 10 / 0; has no defined result whatsoever. Implementation-defined behavior means the implementation must pick one behavior from a set of permitted alternatives and document which one it chose, so the same compiler behaves consistently and predictably. Unspecified behavior means the implementation may choose among permitted alternatives without necessarily documenting which one it picked — the result is one of a known set of possibilities, just not a guaranteed specific one.

Only undefined behavior is truly dangerous to rely on; implementation-defined behavior is safe as long as you consult that compiler's documentation, and unspecified behavior should simply not be depended on for a particular outcome.

Signed Overflow vs Unsigned Wraparound

Signed integer overflow is undefined behavior. When the mathematical result of a signed operation cannot be represented in its type, the standard does not say what happens — and, crucially, you must not assume it silently wraps to the most negative value.

The wrapping intuition many programmers carry over from other languages simply does not apply to signed overflow in C.

Do not assume signed overflow wraps around to INT_MIN — that wraparound behavior belongs to unsigned arithmetic, not signed.

Unsigned arithmetic is different: for an unsigned type of width N, arithmetic is defined to be performed modulo 2^N, so wraparound is well-defined and predictable.

Because unsigned arithmetic is modulo 2^N by definition, x becomes exactly 0 here — a fully specified, portable result.

#include <limits.h>

int x = INT_MAX;
x = x + 1;   // undefined behavior, NOT guaranteed to become INT_MIN
#include <limits.h>

unsigned int x = UINT_MAX;
x = x + 1;   // well-defined: wraps to 0

Division and Remainder by Zero

Both integer division and the remainder operation are undefined when the divisor is zero, whether the zero is a literal or only becomes zero at runtime.

Both operators share the same requirement: the divisor must be nonzero. There is no special case that makes the remainder operator safer than division.

Guarding the divisor before using it in either operator turns a latent crash into a handled, predictable error path.

int a = 10, b = 0;
int quotient  = a / b;   // undefined behavior
int remainder = a % b;   // also undefined behavior
int a = 10, b = 0;
int quotient = 0, remainder = 0;

if (b != 0) {
    quotient  = a / b;
    remainder = a % b;
} else {
    printf("cannot divide by zero\n");
}

Null Pointer Dereference

Dereferencing a null pointer — reading or writing through it — is undefined behavior. It typically crashes on common platforms, but the standard does not guarantee that, and it is not something to rely on for detection.

Checking before dereferencing avoids the immediate crash, but a NULL check alone is not a complete safety strategy.

NULL checks alone are not enough for robust code — good interface design should make clear which pointers can validly be NULL and which are guaranteed non-NULL by contract, rather than sprinkling defensive checks everywhere out of uncertainty.

int *p = NULL;
*p = 10;   // undefined behavior
int *p = NULL;
if (p != NULL) {
    *p = 10;
}

Uninitialized Pointers

A pointer that is declared but never given a value does not automatically become NULL — it holds an indeterminate value, which is effectively a random address. Dereferencing it is dangerous in a different way than a null-pointer dereference: it may write into memory that happens to belong to something else entirely.

Every pointer should be initialized at the point of declaration, either to a real object's address or explicitly to NULL.

int *p;
*p = 10;   // dangerous: p's value is indeterminate, not NULL
int value = 0;
int *p = &value;   // or: int *p = NULL; if there is nothing to point to yet

Dangling Pointers

A dangling pointer is one that still holds the address of an object whose lifetime has already ended. The pointer's bit pattern has not changed, but the object it once referred to is gone — dereferencing it is undefined behavior.

0x1000 (freed) After free(p), p still holds the old address, but the object at that address no longer belongs to the program — the box p points at is gone, even though p's own value has not changed.

Setting p to NULL right after freeing does not revive the freed object — it simply guarantees that any accidental later dereference of p fails loudly instead of silently touching freed memory.

free(p); p = NULL; is a defensive habit worth building automatically — it turns a potential dangling-pointer bug into an immediate, debuggable null-pointer dereference instead.

int *p = malloc(sizeof *p);
*p = 42;
free(p);

*p = 20;   // undefined behavior: p is now dangling
free(p);
p = NULL;   // removes the stale address from p

Returning the Address of a Local Variable

A local variable with automatic storage duration lives only for as long as the function that declared it is executing. Returning its address hands the caller a pointer to storage that no longer belongs to anything the moment the function returns.

10 (gone after return) The returned pointer still holds value's old address, but that stack slot is reused for other calls the instant get_value returns — the pointer is dangling before the caller ever touches it.

Passing caller-owned storage in, or returning a properly-owned dynamically allocated object, avoids handing back a pointer to storage that is about to disappear.

int *get_value(void) {
    int value = 10;
    return &value;   // value's lifetime ends when the function returns
}
void get_value(int *result) {
    *result = 10;   // writes into storage the caller owns
}

int main(void) {
    int value;
    get_value(&value);
    printf("%d\n", value);
    return 0;
}

Accessing an Array Out of Bounds

An array of N elements only has valid indexes 0 through N-1. Reading or writing at any other index is undefined behavior, even if the access happens to land inside memory the program otherwise owns.

Using the array's own known size as the loop bound, with a strict less-than comparison, keeps every index inside the valid range.

int values[5];
values[5] = 100;   // invalid: valid indexes are 0..4
int values[5];
for (size_t i = 0; i < 5; i++) {
    values[i] = 0;
}

Off-by-One Errors

The single most common way programs accidentally step out of bounds is an off-by-one error in a loop condition — using <= where < was needed, or starting from 1 where 0 was needed.

Changing <= to < is the entire fix — the loop now stops at i == 4, the last valid index.

Memorize the rule directly: for N elements, the valid indexes are 0 through N-1, never 0 through N.

int values[5];
for (int i = 0; i <= 5; i++) {
    values[i] = 0;   // i reaches 5 on the last iteration: out of bounds
}
int values[5];
for (int i = 0; i < 5; i++) {
    values[i] = 0;
}

Use-After-Free

Once free() has released a dynamically allocated object, any further read or write through a pointer to that object is undefined behavior — the memory may already have been handed to a different allocation entirely.

Doing all necessary reads and writes before freeing, then immediately clearing the pointer, removes any chance of an accidental later access.

int *p = malloc(sizeof *p);
*p = 42;
free(p);
printf("%d\n", *p);   // use-after-free: undefined behavior
int *p = malloc(sizeof *p);
*p = 42;
printf("%d\n", *p);
free(p);
p = NULL;   // p is never dereferenced again unless reassigned

Double Free

Calling free() twice on the same allocation is undefined behavior — the allocator's internal bookkeeping for that memory has already been reused or invalidated by the first call.

free(NULL) is defined by the standard to be a safe no-op, which is exactly why clearing a pointer to NULL right after freeing it makes a later, redundant free harmless instead of catastrophic.

int *p = malloc(sizeof *p);
free(p);
free(p);   // undefined behavior: double free
int *p = malloc(sizeof *p);
free(p);
p = NULL;

/* ... later ... */
free(p);   // fine: free(NULL) is explicitly permitted and does nothing

Memory Leaks

A memory leak happens when a program loses every reference to a dynamically allocated block without ever freeing it — the memory stays reserved for the life of the program with no way to reclaim it.

The complete correct pattern is: check the allocation, use the pointer, free it exactly once, then clear it — every successful allocation needs a clear release plan from the moment it is made.

int *p = malloc(sizeof *p);
p = NULL;   // the original allocation's address is gone: it can never be freed
int *p = malloc(sizeof *p);
if (p == NULL) {
    return 1;
}

*p = 42;
printf("%d\n", *p);

free(p);
p = NULL;

Object Lifetime Governs Every Pointer

Dangling pointers, use-after-free, and returning the address of a local variable are really three faces of the same underlying rule: a pointer's own value never keeps the object it refers to alive. Once an object's lifetime ends — because a function returned, because free() ran, or because storage was otherwise reclaimed — every pointer that still holds its address is dangling, regardless of how the lifetime ended.

Whether the object was an automatic local, a freed heap allocation, or something else entirely, the same principle applies: a pointer value alone tells you nothing about whether the object it points to is still alive.

int *get_pointer(void) {
    int local = 10;
    return &local;   // local's automatic lifetime ends at return
}

Reading an Uninitialized Automatic Variable

A local variable with automatic storage duration that is never given a value holds an indeterminate value — reading it before it has been assigned can be undefined behavior depending on the type and how the value is used.

Initializing at the point of declaration removes the indeterminate state entirely, so there is never a window where the variable's value is unknown.

Compiler warnings such as -Wall frequently flag "variable may be used uninitialized" — take that warning seriously rather than silencing it.

int x;
printf("%d\n", x);   // x has never been assigned a value
int x = 0;
printf("%d\n", x);

Format String Mismatches

printf and scanf family functions trust the format string to describe the types of the arguments that follow. When the conversion specifier does not match the argument's actual type, the behavior is undefined.

Every conversion specifier has an exact expected argument type — %d for int, %f for double, %s for a char pointer, and so on — and matching them correctly is not optional.

int value = 42;
printf("%f\n", value);   // mismatch: %f expects a double, value is an int
int value = 42;
printf("%d\n", value);

double amount = 42.5;
printf("%f\n", amount);

Incorrect scanf() Arguments

scanf needs the address of each object it writes into, not the object's value — forgetting the & is one of the most common beginner mistakes in C, and it is undefined behavior rather than a compile error in many cases.

scanf always needs an address to write through; note also that scanf uses %lf for a double argument even though printf uses %f for the same type.

int value;
scanf("%d", value);    // wrong: passes value's (indeterminate) contents as an address
int value;
scanf("%d", &value);   // correct: passes value's address

float f;
scanf("%f", &f);

double d;
scanf("%lf", &d);        // note: %lf for double, unlike printf's %f

Unsafe String Input

scanf's %s conversion has no idea how big the destination buffer is unless you tell it — without a width limit, input longer than the buffer overflows it and writes past the end.

A width specifier one less than the buffer's size always leaves room for the null terminator scanf appends; fgets is generally the safer choice for line-oriented input because it always takes an explicit buffer size.

char buffer[10];
scanf("%s", buffer);   // no width limit: long input overflows buffer
char buffer[10];
scanf("%9s", buffer);          // width limit leaves room for the terminator

/* or, for reading a whole line safely: */
char line[80];
fgets(line, sizeof line, stdin);

Forgetting the Null Terminator

A C string is defined by convention as a sequence of characters ending in a null byte — an array of characters without room for that terminator is not a C string, even if it happens to hold readable text.

String-handling functions like strlen, strcpy, and printf's %s all rely on finding a terminating null byte — without it, they read past the end of the array looking for one.

char text[5] = {'H', 'e', 'l', 'l', 'o'};   // no room left for '\0': not a C string
char text[6] = "Hello";   // the extra byte holds the terminator automatically

Modifying a String Literal

A string literal like "Hello" may be stored in read-only memory, and attempting to modify it through a pointer is undefined behavior — even though the syntax looks identical to modifying an ordinary array.

Declaring the destination as an array rather than a pointer copies the literal's characters into freshly owned, writable storage.

char *message = "Hello";
message[0] = 'J';   // undefined behavior: string literals are not modifiable
char message[] = "Hello";
message[0] = 'J';   // fine: message is its own modifiable array copy

strcpy() and Buffer Size

strcpy copies until it finds the source's terminating null byte, with no awareness of how large the destination buffer actually is. If the source string is longer than the destination can hold, the copy overflows it.

Checking the source's length against the destination's capacity before copying — leaving room for the terminator — turns a potential overflow into a handled condition.

Never copy into a fixed-size buffer without first confirming that the source actually fits inside it.

char destination[10];
strcpy(destination, source);   // unsafe if source is longer than 9 characters
char destination[10];
if (strlen(source) < sizeof destination) {
    strcpy(destination, source);
} else {
    /* handle the string being too long */
}

memcpy() and Overlapping Objects

memcpy requires that its source and destination ranges not overlap at all — if they do, the result is undefined. memmove exists specifically to handle the overlapping case correctly.

memmove typically works by copying through a temporary buffer or choosing a safe copy direction, at a small performance cost compared to memcpy.

Rule of thumb: memcpy is for non-overlapping regions only; reach for memmove the moment overlap is even possible.

char buffer[20] = "0123456789";
memcpy(buffer + 1, buffer, 10);   // wrong: source and destination overlap
char buffer[20] = "0123456789";
memmove(buffer + 1, buffer, 10);   // correct: memmove tolerates overlap

Invalid Pointer Arithmetic

For an array of N elements, only the addresses from the first element up to one-past-the-last element are valid pointer values to form — values + 0 through values + N. The one-past-the-end address may be computed and compared, but it must never be dereferenced.

values + 5 is a legitimate address to compute for use as a loop's stopping point, but reading or writing through it steps outside the array entirely.

int values[5];
int *end = values + 5;   // valid to form and compare, one-past-the-end

int bad = *(values + 5);   // invalid: dereferencing one-past-the-end

Comparing Unrelated Pointers

Relational comparisons like p < q are only meaningful when both pointers point into the same array object (or one past its end). Using < or > to compare pointers into two unrelated objects, hoping to learn "which address is lower", is not something the language defines a portable meaning for.

int a[5], b[5];
int *p = &a[0];
int *q = &b[0];

if (p < q) { /* not portably meaningful: a and b are unrelated objects */ }

Invalid Pointer Dereference

Casting an arbitrary integer to a pointer type and dereferencing it does not make that address a valid C object just because the bit pattern fits in a pointer.

Memory-mapped I/O and similarly constructed pointers are a separate, implementation-specific arrangement with their own documented requirements — they are not a general license to dereference arbitrary addresses.

int *p = (int *)0x1234;
printf("%d\n", *p);   // undefined behavior: 0x1234 is not a valid object address

Strict Aliasing, Effective Type, and Type Punning

C's strict aliasing rule says an object generally may only be accessed through an lvalue of its own type (or a few permitted exceptions). Reinterpreting the bytes of one type as another through an incompatible pointer type violates that rule and is undefined behavior.

This is often called type punning — trying to look at one type's storage as though it held another type. Casting the pointer does not change what the underlying object actually is, and accessing it through an incompatible lvalue type is still a violation even though the cast itself compiles without complaint.

Copying the raw bytes with memcpy into an object of the destination type is the portable, well-defined way to inspect or reinterpret a representation — it sidesteps the aliasing rule entirely rather than trying to work around it.

Use memcpy for byte-level reinterpretation only when the sizes and semantics genuinely match — it copies bytes, it does not perform any numeric conversion.

int x = 10;
float *p = (float *)&x;
float reinterpreted = *p;   // undefined behavior: violates strict aliasing
#include <string.h>

int x = 10;
float f;
memcpy(&f, &x, sizeof f);   // portable way to reinterpret bytes, when sizes match

Unsequenced Side Effects

Modifying the same scalar object more than once, or reading it after modifying it, without a defined ordering between those actions inside the same expression is undefined behavior.

Splitting the modifications into separate statements gives each one a clear, sequenced point in the program, removing any ambiguity about ordering.

Avoid modifying the same scalar variable more than once within a single expression — split the modifications across separate statements instead.

int i = 1;
int result = i++ + ++i;   // undefined behavior: unsequenced modifications of i
int i = 1;
int a = i++;
int b = ++i;
int result = a + b;

Argument Evaluation Order and Sequence Points

The order in which a function's arguments are evaluated is not fixed by the standard — code that depends on one argument's side effect happening before another's is relying on something the language does not guarantee.

Do not write code whose correctness depends on the order side effects in separate function arguments occur — that order can differ between compilers or even between calls.

Older material describes these ordering rules using the term "sequence point"; modern standards describe the same ideas with sequenced-before and indeterminately-sequenced terminology. The practical rule is unchanged either way: never write an expression that depends on multiple unsequenced modifications, or conflicting accesses, to the same scalar.

printf("%d %d\n", f1(), f2());   // order of evaluating f1() vs f2() is not fixed

Shift Operation Pitfalls

The bitwise shift operators have real limits on the shift count that the standard defines behavior for. Shifting by a count equal to or greater than the operand's width, or by a negative count, is undefined behavior.

Left-shifting a signed value into or past its sign bit is a related trap — the result is not guaranteed to be one particular negative value across every implementation, even though many compilers produce a consistent-looking result.

Unsigned types have well-defined shift and overflow behavior across their full range, which is why bit manipulation code generally prefers them — but shift counts must still stay within the type's width.

int x = 1;
int y = x << 32;   // undefined: shift count equals the width of a 32-bit int
int z = x << -1;   // undefined: negative shift count
int x = 1;
int y = x << 31;   // do not assume a specific negative value on every implementation
unsigned int x = 1u;
x <<= 5;   // prefer unsigned types for bit-level work

Pointer Lifetime and Ownership

Every pointer that can refer to dynamically allocated memory needs a clear answer to one question: who is responsible for freeing it? Without an explicit ownership convention, memory leaks and use-after-free errors both become far more likely.

A well-documented ownership convention describes the whole lifecycle in one sentence: the function creates the object, the caller takes ownership, the caller uses it, and the caller eventually frees it.

State ownership explicitly in a function's documentation or naming convention whenever it can return or accept a pointer to dynamically allocated memory — do not leave callers to guess.

/* declared in a header: */
int *create_value(void);   /* caller owns the returned pointer and must free() it */

Header and Multi-File Definition Pitfalls

Putting an actual variable or function definition directly in a header file causes trouble the moment more than one .c file includes that header — each translation unit gets its own copy of the definition, and the linker sees multiple definitions of the same symbol.

extern in the header tells every including file that counter exists somewhere without allocating storage for it again; exactly one .c file supplies the single real definition.

The same problem applies to functions: writing a full function body in a header, rather than just its prototype, produces a duplicate external definition wherever more than one file includes that header.

/* counter.h — wrong: defines storage directly in the header */
int counter;
/* counter.h — correct: only declares that counter exists elsewhere */
extern int counter;

/* counter.c — exactly one file provides the real definition */
int counter;
/* math_utils.h — correct: declaration only */
int add(int a, int b);

/* math_utils.c — the one definition */
int add(int a, int b) {
    return a + b;
}

Preprocessor Macro Pitfalls

Function-like macros are expanded by simple, unintelligent text substitution — without parentheses around the parameter and around the whole expansion, operator precedence can completely change the meaning of the macro call.

Parenthesizing both the parameter and the entire expansion protects the macro from operator-precedence surprises at the call site.

Even fully parenthesized, SQUARE still evaluates its argument twice — SQUARE(i++) increments i twice in one call, which is a very different bug than missing parentheses and is not fixable by adding more of them.

#define SQUARE(x) x*x

int result = SQUARE(2 + 3);   // expands to: 2 + 3*2 + 3  ->  11, not 25
#define SQUARE(x) ((x) * (x))

int result = SQUARE(2 + 3);   // expands to: ((2 + 3) * (2 + 3))  ->  25

Macro vs Function

When ordinary type checking and normal function-call semantics are enough, a real function is almost always the better choice over a function-like macro — it avoids double-evaluation, gets proper type checking, and is far easier to step through in a debugger.

A function evaluates each argument exactly once and gives the compiler real type information to check — macros remain legitimate for conditional compilation, compile-time configuration, and specialized low-level code where a function genuinely cannot do the job.

#define MAX(a, b) ((a) > (b) ? (a) : (b))   // no type checking, can double-evaluate a or b
int max_int(int a, int b) {
    return a > b ? a : b;
}

Error Handling Is Part of Correctness

Skipping the check on a function that can fail is not an optimization — it is a bet that failure will never happen, and using an invalid result afterward is undefined behavior in its own right.

Checking fopen's return value before using the stream turns a missing or unreadable file into a handled error instead of undefined behavior on a NULL FILE pointer.

FILE *file = fopen("data.txt", "r");
fscanf(file, "%d", &value);   // file may be NULL if fopen failed
FILE *file = fopen("data.txt", "r");
if (file == NULL) {
    /* handle the error */
    return 1;
}

fscanf(file, "%d", &value);
fclose(file);

Always Check Allocation Results

malloc, calloc, and realloc can all fail and return NULL, most commonly when the system is out of memory. Do not assume allocation always succeeds.

Dereferencing a pointer from a failed allocation is undefined behavior — the NULL check must happen before the pointer is used for anything.

int *p = malloc(100 * sizeof *p);
if (p == NULL) {
    /* allocation failed: handle it, do not dereference p */
    return 1;
}

Check Integer Overflow in Size Calculations

A size calculation like count multiplied by an element size can itself overflow size_t before malloc is ever called, particularly when count comes from untrusted input — the overflowed, too-small value then gets passed straight to malloc.

Dividing SIZE_MAX by the element size first, and comparing count against that limit, detects the overflow before the multiplication ever happens — critical in any code that allocates based on untrusted or externally supplied sizes.

size_t bytes = count * sizeof(int);   // can overflow size_t if count is very large
int *p = malloc(bytes);
#include <stdint.h>

if (count > SIZE_MAX / sizeof(int)) {
    /* the multiplication would overflow: handle as an error */
    return NULL;
}

size_t bytes = count * sizeof(int);
int *p = malloc(bytes);

Prefer the sizeof *pointer Idiom

Writing sizeof *p instead of sizeof(int) in an allocation expression removes the need to repeat the pointer's type by name — if the pointer's declared type ever changes, the allocation expression automatically stays correct.

sizeof *p evaluates the size of whatever type p currently points to, without actually dereferencing p — no risk is introduced even though the expression looks like it reads through the pointer.

int *p = malloc(100 * sizeof(int));   // repeats the type name — must be kept in sync manually
int *p = malloc(100 * sizeof *p);   // stays correct even if p's type is later changed

Avoid Magic Numbers

An unexplained numeric literal buried in code forces every future reader to guess what it means and to hunt down every other place the same value needs to change together.

A named constant documents its own purpose and gives a single place to change the value later, instead of a literal scattered across the codebase.

char buffer[1024];   // why 1024? readers have to guess
#define BUFFER_SIZE 1024
char buffer[BUFFER_SIZE];

/* or, for a compile-time constant with its own type: */
enum { BUFFER_SIZE2 = 1024 };

Prefer Clear Initialization

Declaring a variable and assigning its first value in two separate steps leaves a visible window where the variable exists uninitialized, and it is simply more to read than doing both at once.

Combining declaration and initialization removes the uninitialized window entirely; for arrays, an initializer with a single 0 zero-fills every remaining element too, sharply reducing the chance of an accidental read of indeterminate data.

int value;
value = 0;
int value = 0;

int values[10] = {0};   // zero-initializes every element of the array

Keep Functions Small and Focused

A function that performs several unrelated tasks in sequence is hard to verify, because every one of its internal states has to be understood together rather than checked independently.

Splitting the work into small, single-purpose functions means each one can be understood, tested, and trusted on its own — with far fewer combined states to reason about at once.

void do_everything(void) {
    /* read input, validate it, compute a result, and write output,
       all tangled together in one large function */
}
read_input();
validate_input();
calculate_result();
write_output();

Minimize Global State

A global variable that any file can modify directly makes it very difficult to know, just by reading one function, who else might be changing it and when.

Keeping the actual variable static and private, and exposing only controlled operations on it, means every modification goes through code that can validate, log, or restrict how the state changes.

int global_counter;   // any file that can see this can modify it directly
static int counter;   // private to this file

void counter_increment(void) {
    counter++;
}

int counter_get(void) {
    return counter;
}

Use const to Express Intent

Marking a pointer parameter const documents, and lets the compiler enforce, a promise that the function will not modify the object through that pointer — a contract both the compiler and future readers can rely on.

void print_data(const char *data) {
    printf("%s\n", data);
}

const int value = 10;   // similarly documents that value is never meant to change

Use static for Private Module Details

Giving a file-local helper function internal linkage with static prevents it from being called by mistake from another file, and it signals directly to readers that this function is a private implementation detail of the file it lives in.

static int validate_input(int value) {
    return value >= 0;
}

Use Meaningful Names

Single-letter or otherwise vague variable names for concepts that matter make it easy to misuse a value simply because it is unclear what the value represents.

Names that describe what a value means, and often its unit, reduce the chance of accidentally using the wrong variable in the wrong place.

int x, y, z;   // what do these actually represent?
int sensor_count;
int retry_limit;
int timeout_ms;

Use Braces Consistently

A single-statement if or loop without braces looks fine until someone adds a second statement later, intending it to be part of the block — without braces, that new statement silently falls outside the control structure.

Always writing braces, even for a single statement today, removes this entire category of mistake for whoever edits the code next.

if (condition)
    do_something();
    do_something_else();   // this line always runs, regardless of condition
if (condition) {
    do_something();
    do_something_else();
}

Avoid Deeply Clever Code

Packing multiple side effects into one dense expression makes code harder to review and test, and it can slide into undefined behavior without the author even noticing.

Writing out each step explicitly costs a few extra lines but removes all ambiguity about ordering, and is far easier for a reviewer — or a future you — to verify.

int y = 1;
int x = y++ + ++y;   // hard to reason about, and can be undefined behavior
int y = 1;
int first = y;
y++;
y++;
int second = y;
int x = first + second;

Compiler Warnings

Compiler warnings are one of the most effective tools available for finding mistakes before they ever reach a test run — the compiler is already looking closely at every line of the program anyway.

Enabling a strong set of warning flags in every development build surfaces uninitialized variables, mismatched types, unused values, and many other latent bugs early.

Treat important warnings as problems to fix, not noise to suppress reflexively with a cast or a pragma — a warning is frequently pointing at a real undefined-behavior risk.

gcc -Wall -Wextra -Wpedantic -o program program.c

Static Analysis

Static-analysis tools examine source code without ever executing it, looking for patterns known to be dangerous: possible null-pointer dereferences, memory leaks, unreachable code, suspicious implicit conversions, buffer-related problems, uninitialized values, and API misuse.

These tools are especially valuable on large or safety-critical codebases, where manual review of every code path is impractical and a single missed pattern can be costly.

Sanitizers

Modern compilers can instrument a build so it detects certain classes of bugs while the program is actually running its test suite, rather than relying on the bug happening to crash on its own.

AddressSanitizer (ASan) catches out-of-bounds access and use-after-free. UndefinedBehaviorSanitizer (UBSan) catches many other forms of undefined behavior, such as signed overflow and invalid shifts. MemorySanitizer (MSan) catches reads of uninitialized memory. ThreadSanitizer (TSan) catches data races between threads.

Running the test suite under a sanitizer build turns many silent undefined-behavior bugs into an immediate, precise diagnostic report instead of a mysterious later crash.

gcc -fsanitize=address,undefined -g -o program program.c

Assertions

assert documents an internal invariant — something the programmer believes must always be true at that point — and aborts loudly during development if it is ever violated.

assert should not be the only mechanism for handling expected runtime failures coming from outside the program — assert(user_input >= 0); is not a substitute for real input validation, because assertions are typically compiled out entirely in release builds.

#include <assert.h>

void process(int *data) {
    assert(data != NULL);
    /* ... */
}

Defensive Programming

A function whose caller must guess an undocumented expectation invites exactly the kind of mismatch that leads to undefined behavior — defensive programming means making the contract explicit and checking it where it matters.

Stating and, where practical, checking the required relationship between parameters matters most at the boundaries where a program meets the outside world — external input, hardware, files, network data, dynamic memory, and concurrency.

void process(char *buffer);   // undocumented: how much data does buffer hold?
int process(const char *buffer, size_t length);   // the contract is explicit

Bounds and Capacity Should Travel Together

A raw pointer parameter alone tells a function nothing about how much memory is actually available at that address — the available capacity has to travel alongside the pointer for the function to operate safely.

This pairing applies broadly — arrays, strings, buffers, and dynamically allocated memory should all be passed around together with an explicit size or capacity rather than as a bare pointer.

void process(char *buffer);   // buffer's available size is unknown here
void process(char *buffer, size_t capacity) {
    /* capacity tells the function exactly how far it may safely write */
}

Resource Management and Consistent Cleanup

Memory is not the only resource a program can leak — files, sockets, locks, and device handles all follow the same lifecycle: acquire, check that the acquisition succeeded, use the resource, and release it.

Functions with several possible failure points are where cleanup logic goes wrong most often — each failure path has to release exactly the resources that were successfully acquired before that point, and nothing more.

Every successful acquisition needs a matching release on every exit path out of the function, including the early-return failure paths — not just the final success path.

FILE *file = fopen("data.txt", "r");
if (file == NULL) {
    return 1;
}

/* use file */

fclose(file);
int process_two_files(const char *path1, const char *path2) {
    FILE *f1 = fopen(path1, "r");
    if (f1 == NULL) {
        return 1;
    }

    FILE *f2 = fopen(path2, "r");
    if (f2 == NULL) {
        fclose(f1);   // clean up what was already acquired
        return 1;
    }

    /* use f1 and f2 */

    fclose(f2);
    fclose(f1);
    return 0;
}

Undefined Behavior and Compiler Optimization

Because signed overflow is undefined behavior, an optimizing compiler is entitled to assume it never happens in any valid execution — and it can transform code based on that assumption in ways that look surprising if you mentally model UB as "it just wraps around."

This looks like it should always be true, but if x were INT_MAX, x + 1 would overflow. Because the compiler may assume overflow never happens, it can optimize this to simply return 1 unconditionally — which is correct for every non-overflowing input, but diverges from a naive wraparound mental model for the overflowing one.

The overall flow is: source code goes in, the compiler assumes undefined behavior never occurs in a valid execution, optimization is applied on top of that assumption, and machine code comes out. Once undefined behavior actually exists in the source, reasoning backward from the generated machine code is no longer a reliable way to understand what the C program was supposed to mean.

A program working correctly under one compiler with optimizations disabled, on one machine, is not proof of correctness — the same source can behave differently under a different compiler, a different optimization level such as -O2 instead of -O0, or a different machine, precisely because of latent undefined behavior. Correctness has to rest on the language's rules and documented guarantees, never on a single observed run.

int f(int x) {
    return x + 1 > x;
}

Portability Pitfalls

C intentionally leaves several basic facts about the machine unspecified or implementation-defined, and code that quietly assumes one particular answer everywhere is not portable.

Do not assume int is always 32 bits, that char is always signed, that pointers are always 32 bits wide, that little-endian byte order is universal, or that sizeof(long) is always 4 — all of these vary across real, actively used platforms.

When exact widths genuinely matter, the fixed-width types from stdint.h say precisely what is meant instead of relying on int, long, or char having a particular size on a particular platform.

#include <stdint.h>

uint32_t identifier;
int16_t small_value;
uint8_t byte_flag;

Use size_t for Object Sizes

Array sizes, allocation byte counts, and the result of sizeof are all naturally expressed as size_t — the unsigned type C's own object-size operations use — rather than as a plain int, which can be too narrow or the wrong signedness for very large sizes.

Using size_t for lengths and sizes keeps the type consistent with what sizeof and the standard library's own size-related functions already use, avoiding signed/unsigned mismatches later.

int buffer[100];
size_t length = sizeof buffer / sizeof buffer[0];

Avoid Dangerous Integer Conversions

When a signed and an unsigned value are compared or combined, C's usual arithmetic conversions can convert the signed value to unsigned in a way that produces a surprising result.

-1 converted to unsigned int becomes UINT_MAX, not a small negative-feeling number, so the comparison silently does something very different from what the literal values -1 and 10 suggest.

Before mixing signed and unsigned values in the same expression, make sure you understand C's integer promotion and signed/unsigned conversion rules — this is one of the most common sources of quiet logic bugs in otherwise correct-looking code.

unsigned int u = 10;
int x = -1;

if (x < u) {
    /* x is converted to unsigned here: -1 becomes a very large unsigned value,
       so this comparison does NOT behave the way it looks like it should */
}

Check Return Values

Ignoring a function's return value lets a program keep running on an assumption that may already be false — fgets returning NULL on input failure or end-of-file, and fopen returning NULL on a missing or unreadable file, are two of the most common examples.

Any standard library function that reports success or failure through its return value is telling you something you need to check before trusting its output.

char buffer[80];
if (fgets(buffer, sizeof buffer, stdin) == NULL) {
    /* input failure or end-of-file: handle it, do not use buffer's contents */
}

Best Practices Checklist

Before trusting a piece of C code, it helps to run through the same categories of self-check every time.

Memory: Was every allocation's result checked? Is every allocation released exactly once? Are freed objects never accessed again? Are array and pointer bounds respected everywhere? Are local variables' lifetimes respected by every pointer to them?

Pointers: Is every pointer initialized before use? Is NULL a possibility that has been accounted for? Is every dereference known to be valid? Is ownership of every dynamically allocated pointer clearly assigned? Does all pointer arithmetic stay within the rules?

Strings: Is there always room for the terminating null byte? Are input sizes bounded? Is destination capacity checked before every copy? Are string literals always treated as immutable?

Integers: Is overflow possible anywhere, and has it been guarded against? Is mixing signed and unsigned values safe in every comparison? Are shift counts always valid? Are conversions between integer types intentional rather than accidental?

APIs: Are return values checked? Do arguments match what each function actually declares? Are format strings correct for their arguments? Are resources released on every possible exit path?

Design: Are interfaces and their contracts clear? Is mutable global state kept to a minimum? Are implementation details kept private? Are assumptions the code depends on written down somewhere?

A Safer C Example

Pulling several of these practices together, here is a small program that validates its allocation size, checks the allocation itself, uses only valid indexes, and cleans up properly before exiting.

Validate the requested size against SIZE_MAX / sizeof(int) before ever multiplying, to rule out an overflowing size calculation.

Call malloc with the now-safe byte count and immediately check the result for NULL before doing anything else with it.

Loop using size_t and a strict less-than bound, so every index touched is inside 0..count-1.

Use the data only after it has been safely initialized.

Free the allocation exactly once, then set the pointer to NULL so any accidental later use is caught immediately.

Each step in this program corresponds directly to one of the pitfalls covered earlier: overflow-safe sizing, a checked allocation, in-bounds access, and a clean, single release.

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    size_t count = 10;

    if (count > SIZE_MAX / sizeof(int)) {
        fprintf(stderr, "requested size is too large\n");
        return 1;
    }

    int *values = malloc(count * sizeof *values);
    if (values == NULL) {
        fprintf(stderr, "allocation failed\n");
        return 1;
    }

    for (size_t i = 0; i < count; i++) {
        values[i] = 0;
    }

    printf("%d\n", values[0]);

    free(values);
    values = NULL;

    return 0;
}

Common Pitfalls Quick Reference

A compact pairing of each pitfall with its typical consequence, useful as a final review before shipping.

Pitfall                                Consequence
--------------------------------------  ---------------------------------------
Signed integer overflow                 Undefined behavior; do not assume wraparound
Division or remainder by zero           Undefined behavior; may not simply crash
Out-of-bounds array access              Undefined behavior; may corrupt other data
Null pointer dereference                Undefined behavior; typically crashes
Use-after-free                          Undefined behavior; reads/writes freed memory
Returning a local variable's address    Dangling pointer; storage is already gone
Double free                             Undefined behavior; corrupts allocator state
Reading uninitialized data              Undefined behavior; value is indeterminate
Modifying a string literal              Undefined behavior; literals may be read-only
Overlapping memcpy                      Undefined behavior; use memmove instead
Invalid shift count                     Undefined behavior; keep counts within width
Unsequenced conflicting side effects    Undefined behavior; split into statements
Incorrect printf format specifier       Undefined behavior; types must ma
…

Golden Rules for Safe C

Fifteen rules worth internalizing, distilled from everything above.

What is the difference between undefined behavior, implementation-defined behavior, and unspecified behavior?

Undefined behavior means the standard places no requirements on the result at all. Implementation-defined behavior means the implementation must pick one behavior from a documented set of alternatives. Unspecified behavior means the implementation may choose among permitted alternatives without necessarily documenting which one it picked.

Why is signed integer overflow undefined behavior while unsigned overflow (wraparound) is well-defined?

The standard defines unsigned arithmetic of width N to be performed modulo 2^N, so wraparound for unsigned types is a guaranteed, portable result. Signed overflow has no such guarantee — the standard imposes no requirement on the result when a signed operation's mathematical answer cannot be represented, so it must never be assumed to simply wrap to the minimum value.

After calling free() on a pointer, why is it good practice to immediately set that pointer to NULL, even though doing so does not undo the free?

Setting the pointer to NULL cannot revive the freed object, but it does remove the stale address from the pointer, so any accidental later dereference becomes an immediate, easily diagnosed null-pointer access instead of silent undefined behavior on freed memory — and it also makes a later, redundant free(p) harmless, since free(NULL) is explicitly permitted.

What is the difference between memcpy and memmove, and when must memmove be used instead of memcpy?

memcpy requires that the source and destination ranges never overlap — using it on overlapping ranges is undefined behavior. memmove is defined to work correctly even when the source and destination overlap, typically by copying in a safe order or through a temporary buffer, so it must be used instead whenever overlap is possible.

Why can an optimizing compiler produce results that look surprising for code whose apparent correctness depends on signed overflow not happening?

Because signed overflow is undefined behavior, the compiler is allowed to assume it never occurs in any valid execution of the program, and it can optimize based on that assumption. If the source code actually does overflow, the resulting behavior no longer matches a naive mental model of arithmetic simply wrapping around, because the compiler's optimization was never obligated to account for that case.

Write a C program that safely computes the quotient and remainder of two integers, guarding against division by zero before performing either operation.

Both integer division and the remainder operator are undefined when the divisor is zero, so the program checks b against zero once and only performs either operation inside that guard, printing a clear error message on the zero-divisor path instead of triggering undefined behavior.

Write a C program that dynamically allocates an array of integers, checks the allocation for failure, initializes every element with a bounds-safe loop, prints the values, and frees the memory exactly once.

The program checks malloc's return value before touching it, uses size_t with a strict less-than bound in both loops so every index stays within 0..count-1, and frees the allocation exactly once before clearing the pointer, avoiding a leak, an out-of-bounds access, and a dangling pointer all at once.

Write a C program that demonstrates the free-then-NULL pattern by allocating an integer, freeing it, resetting the pointer to NULL, and showing that the program can safely call free on it again without triggering a double free.

After the first free, the pointer is immediately set to NULL, which removes the dangling address before it can be accidentally dereferenced. Because free(NULL) is defined by the standard to do nothing, the second free call is harmless, demonstrating why clearing a pointer right after freeing it prevents both use-after-free and double-free bugs.

Write a C program that safely reads a line of text from standard input using fgets, checks the return value for failure or end-of-file, and prints the line back without risking a buffer overflow.

fgets always takes an explicit buffer size, so it can never write past the end of line the way an unbounded scanf %s could. Checking its return value for NULL catches input failure or end-of-file before the buffer's contents are trusted, and the trailing newline is stripped safely by checking the last character before removing it.

Write a C function that copies a source string into a fixed-size destination buffer only after confirming the source will fit, instead of calling strcpy unconditionally.

safe_copy compares the source string's length against the destination's capacity, requiring room for the terminating null byte, before ever calling strcpy. This turns what would otherwise be an unchecked buffer overflow into a function that reports failure instead of writing past the end of the destination.

Write a C program that demonstrates copying overlapping regions of the same character buffer correctly using memmove, contrasted with why memcpy would be unsafe for the same operation.

Because the destination buffer + 1 and the source buffer overlap, memcpy's requirement that its ranges never overlap would be violated, making the result undefined. memmove is defined to handle overlapping ranges correctly, so it is used instead, safely shifting the existing characters one position to the right before the first byte is overwritten.

Write a C function that validates a requested element count against SIZE_MAX before multiplying by the element size, to guard against integer overflow in a size calculation passed to malloc.

Dividing SIZE_MAX by the element size first, and rejecting any count larger than that quotient, detects an overflow before the multiplication count * sizeof(int) is ever performed, so the byte count handed to malloc can never wrap around to a small, incorrect value.

Write a C program using a Student struct with roll_no, name, and marks fields that dynamically allocates an array of students, checks the allocation, safely reads each student's name with fgets, prints a report, and frees all memory before exiting.

The program checks the allocation for the whole student array before use, writes each name with snprintf so the fixed-size name buffer can never overflow, uses size_t with a strict bound in every loop, and releases the array exactly once at the end, tying together struct-array allocation, safe string handling, and clean resource release in one program.

Write a C program that opens a file, checks fopen's return value, reads a value from it, and closes the file on every exit path including the error path.

fopen's return value is checked before the stream is ever used, and fscanf's return value is checked before the read value is trusted. Both the success path and the read-failure path close the file exactly once before returning, so no exit from the function leaves the file handle open.

1.  Initialize every variable before reading it.
2.  Stay strictly within an object's boundaries — arrays, buffers, and strings alike.
3.  Respect object lifetimes — never use a pointer once its target's lifetime has ended.
4.  Never dereference a pointer you cannot prove is valid.
5.  Check the result of every memory allocation before using it.
6.  Free every owned allocation exactly once, and never touch it afterward.
7.  Keep source and destination sizes explicit whenever data is copied.
8.  Avoid signed integer overflow — do not rely on any particular wraparound behavior.
9.  Use only valid shift counts, within the operand's actual width.
10. Match function arguments and format strings to their declared types exactly.
11. Never modify a string literal.
12. Never write code whose correctness depends on undefined behavior.
13. Never assume an implementation detail — such as type width or endianness — is universal.
14. Enable and heed compiler warnings.
15. Test with sanitizers and static analysis, not just by eye.