Error Handling and Program Failure
|
This section documents C23 (ISO/IEC 9899:2024), per ISO/IEC JTC1/SC22/WG14’s freely available working draft N3220, which WG14 documents as differing from the published standard only editorially — the reference these pages are written and verified against. This content was generated with the assistance of AI and should be verified against the WG14 draft and cppreference.com’s C reference before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
C has no exceptions and no runtime that catches mistakes. An error is either a value you check or undefined behavior — and undefined behavior means the compiler is entitled to assume it never happens, which is why such bugs manifest as impossible-looking behavior far from their cause.
This page is deliberately organized as a catalogue: knowing what the failure modes are is what makes it possible to write code that avoids them.
The Catalogue of Wrongdoings
Arithmetic Violations
| Operation | Consequence |
|---|---|
Signed integer overflow ( |
Undefined. Not wrapping — the optimizer may assume it cannot occur. |
Division or remainder by zero |
Undefined. No exception, no signal guarantee. |
|
Undefined — the true result is not representable. |
Shift by a negative amount, or by ≥ the operand’s width |
Undefined. |
Left-shifting a signed value into the sign bit |
Undefined. |
Unsigned overflow |
Defined: wraps modulo 2N. The one safe arithmetic overflow. |
Floating-point overflow / division by zero |
Implementation-defined: normally ±infinity or NaN, with |
The fix is <stdckdint.h> (C23) or a pre-check — see
Numbers and Math:
#include <limits.h>
#include <stdckdint.h>
#include <stdio.h>
// C23: checked, and the destination type decides what "fits" means.
static bool add_safely(int a, int b, int *out)
{
return !ckd_add(out, a, b);
}
// Pre-C23: check BEFORE the operation, never after (the overflow is already UB).
static bool add_safely_c11(int a, int b, int *out)
{
if (b > 0 && a > INT_MAX - b) {
return false;
}
if (b < 0 && a < INT_MIN - b) {
return false;
}
*out = a + b;
return true;
}
int main(void)
{
int result = 0;
printf("%d %d\n", add_safely(INT_MAX, 1, &result), add_safely_c11(INT_MAX, 1, &result));
// Write in one statement, read in the next: writing and reading result in a
// single expression would be unsequenced, and so undefined.
bool ok = add_safely(2, 3, &result);
printf("%d %d\n", ok, result);
return 0;
}
Conversion Violations
-
Floating to integer where the truncated value does not fit the target: undefined.
-
Integer to a narrower signed type where the value does not fit: implementation-defined (in practice, the low bits).
-
Pointer to a smaller integer type, or to an incompatible pointer type, then dereferenced: undefined.
-
Implicit signed/unsigned conversion: well-defined but rarely what you meant — the
-1 < 1utrap in Basic Types and Values.
Value Violations
Reading an object that has no determinate value:
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int uninitialized; // automatic storage: INDETERMINATE
// printf("%d\n", uninitialized); // UNDEFINED -- and -Wuninitialized warns
(void)uninitialized;
int initialized = 0; // always initialize at the declaration
void *raw = malloc(16); // malloc: also indeterminate
if (raw == nullptr) {
return EXIT_FAILURE;
}
// Read it only after writing it, or use calloc to get zeros.
unsigned char *bytes = raw;
bytes[0] = 1;
printf("%d %d\n", initialized, bytes[0]);
free(raw);
return 0;
}
Just how bad this is deserves spelling out: an indeterminate value is not merely "some number you did not
choose". Reading it twice may yield two different answers, so even x == x may be false, and the compiler is
free to delete code that depends on it. Clang refuses the attempt outright:
warning: variable 'uninitialized' is uninitialized when used here [-Wuninitialized]
warning: self-comparison always evaluates to true [-Wtautological-compare]
-fsanitize=memory (Clang) finds the cases the compiler cannot see statically.
Type Violations
Accessing an object through an lvalue of an incompatible type — the strict aliasing rule, covered in
Memory Model and Alignment. Use memcpy or a
union.
Access Violations
The category with the security consequences:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
int values[4] = { 1, 2, 3, 4 };
size_t index = 4;
// Out of bounds: values[4] does not exist. Nothing checks this.
if (index < sizeof values / sizeof values[0]) { // the check you must write
printf("%d\n", values[index]);
} else {
puts("index rejected");
}
// Buffer overflow through an unchecked copy:
char small[8];
const char *source = "this is far too long";
// strcpy(small, source); // OVERFLOW -- UB
if (snprintf(small, sizeof small, "%s", source) >= (int)sizeof small) {
puts("truncated rather than overflowed");
}
// Use-after-free and double free:
char *p = malloc(16);
if (p == nullptr) {
return EXIT_FAILURE;
}
strcpy(p, "ok");
free(p);
p = nullptr; // makes a later use a null deref, not a UAF
free(p); // free(nullptr) is explicitly safe
return 0;
}
The complete list: out-of-bounds read or write, null pointer dereference, use-after-free, double free, use-after-return (a pointer to a dead local), misaligned access, and reading past the end of a non-NUL-terminated "string".
Misinterpretation and Invalidation
-
Misinterpretation: a
printfconversion that does not match its argument, ava_argtype mismatch, a wrong-signature callback called through a converted function pointer. All undefined, none checked at run time.-Wformatcovers theprintfcase. -
Invalidation: keeping a pointer or iterator past the point where the object it names may move — notably after
realloc(the old pointer is dead), afterfree, when astaticbuffer is reused (strtok,localtime,getenv), or into a compound literal whose block has ended.
State Degradation
Failures that are not undefined behavior in a single operation but exhaust a resource:
| Failure | Cause and mitigation |
|---|---|
Stack exhaustion |
Unbounded recursion, or a large local/VLA. C guarantees no stack depth. Bound the recursion, move large buffers to the heap, and never size a VLA from input. |
Memory exhaustion |
Leaks, or an unbounded allocation from input. Check every allocation, cap sizes computed from input, and run with LeakSanitizer. |
File-descriptor exhaustion |
Not closing on the error path. This is what the |
Integer-index drift |
A counter that grows unbounded until it overflows. Use |
Races and Deadlocks
Unsynchronized concurrent access to the same object is a data race and undefined — and deadlock is a liveness failure the language cannot detect for you. Both are covered in Threads and Atomics and Memory Consistency. The short form: share nothing, or protect it with a mutex or an atomic, and take locks in a globally consistent order.
Dealing with Failure
Return an Error Code
The dominant C convention: the return value carries success or failure; results go through out parameters.
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// A project-specific status enum: explicit, exhaustive, and switchable.
enum Status {
STATUS_OK = 0,
STATUS_INVALID_ARGUMENT,
STATUS_OUT_OF_MEMORY,
STATUS_IO_ERROR,
STATUS_NOT_FOUND,
};
static const char *status_message(enum Status status)
{
switch (status) {
case STATUS_OK: return "ok";
case STATUS_INVALID_ARGUMENT: return "invalid argument";
case STATUS_OUT_OF_MEMORY: return "out of memory";
case STATUS_IO_ERROR: return "I/O error";
case STATUS_NOT_FOUND: return "not found";
default: return "unknown status";
}
}
// The result goes through an out parameter; the return value is the status.
static enum Status read_first_line(const char *path, char **out_line)
{
if (path == nullptr || out_line == nullptr) {
return STATUS_INVALID_ARGUMENT;
}
*out_line = nullptr;
FILE *f = fopen(path, "r");
if (f == nullptr) {
return errno == ENOENT ? STATUS_NOT_FOUND : STATUS_IO_ERROR;
}
char buffer[256];
enum Status status = STATUS_OK;
if (fgets(buffer, sizeof buffer, f) == nullptr) {
status = ferror(f) ? STATUS_IO_ERROR : STATUS_NOT_FOUND;
goto out;
}
*out_line = strdup(buffer);
if (*out_line == nullptr) {
status = STATUS_OUT_OF_MEMORY;
}
out:
fclose(f);
return status;
}
int main(void)
{
char *line = nullptr;
enum Status status = read_first_line("/nonexistent/file", &line);
printf("status: %s\n", status_message(status));
free(line);
return status == STATUS_OK ? EXIT_SUCCESS : EXIT_FAILURE;
}
Design rules that make this pleasant rather than tedious:
-
One status type per module, with a
*_messagefunction. Do not overloadintwith magic numbers. -
Set out parameters to a safe value first (
*out_line = nullptr), so a caller that ignores the status does not read garbage. -
Mark the status
[[nodiscard]]so ignoring it is a warning. -
Never report failure by returning a valid-looking value —
-1for asize_tisSIZE_MAX.
errno Discipline
Recapping the rules from Standard Library Overview, because getting them wrong is so common:
-
errnois meaningful only after a function documented to set it has failed. -
Read (or save) it immediately —
printfmay change it. -
Set
errno = 0before calls that return a valid value on failure (strtol, the maths functions). -
Do not invent your own
E*values; use your own status enum instead.
Cleanup with goto
The single-exit pattern, in full, is the most important structural idiom in C error handling:
#include <stdio.h>
#include <stdlib.h>
static int process(const char *in_path, const char *out_path, size_t buffer_size)
{
int status = -1; // pessimistic default
unsigned char *buffer = nullptr;
FILE *in = nullptr;
FILE *out = nullptr;
buffer = malloc(buffer_size);
if (buffer == nullptr) {
goto cleanup; // nothing acquired yet
}
in = fopen(in_path, "rb");
if (in == nullptr) {
perror("open input");
goto cleanup;
}
out = fopen(out_path, "wb");
if (out == nullptr) {
perror("open output");
goto cleanup;
}
size_t read_count;
while ((read_count = fread(buffer, 1, buffer_size, in)) > 0) {
if (fwrite(buffer, 1, read_count, out) != read_count) {
perror("write");
goto cleanup;
}
}
if (ferror(in)) {
perror("read");
goto cleanup;
}
status = 0; // success only reached here
cleanup:
// One place to release everything; each guard makes the order irrelevant.
if (out != nullptr && fclose(out) != 0 && status == 0) {
perror("close output"); // a close failure IS an error
status = -1;
}
if (in != nullptr) {
fclose(in);
}
free(buffer);
return status;
}
int main(void)
{
return process("/nonexistent/in", "/tmp/c-demo-out.bin", 4096) == 0 ? 0 : 1;
}
Note the detail that most implementations miss: fclose can fail, and on a buffered write stream that is
where a full disk surfaces. Ignoring it silently loses data.
Defensive Assertions
Distinguish the two kinds of check, because they compile differently:
| Check | Use |
|---|---|
|
A programming error — an invariant your own code must uphold. Compiled out
under |
|
Input or environment validation — anything that can legitimately fail at run time. Must survive a release build. |
|
A compile-time fact: type widths, struct layout, configuration sanity. |
|
C23, |
#include <assert.h>
#include <limits.h>
#include <stddef.h>
#include <stdio.h>
static_assert(CHAR_BIT == 8, "this code assumes 8-bit bytes");
enum Direction { DIR_NORTH, DIR_SOUTH, DIR_EAST, DIR_WEST };
static const char *direction_name(enum Direction d)
{
switch (d) {
case DIR_NORTH: return "north";
case DIR_SOUTH: return "south";
case DIR_EAST: return "east";
case DIR_WEST: return "west";
}
// Every enumerator is handled above, so this is genuinely unreachable...
// but prefer returning a fallback to promising UB:
return "unknown";
}
static int average(const int *values, size_t count)
{
assert(values != nullptr); // a precondition: callers must not pass null
assert(count > 0); // ...and must pass a non-empty range
long long total = 0;
for (size_t i = 0; i < count; ++i) {
total += values[i];
}
return (int)(total / (long long)count);
}
int main(void)
{
int values[3] = { 10, 20, 30 };
printf("%s %d\n", direction_name(DIR_EAST), average(values, 3));
return 0;
}
Sanitizers
The tools that turn undefined behavior into a diagnosis. Build with them in development and CI, not in production:
# Address + undefined behavior: the default pairing. Catches out-of-bounds,
# use-after-free, leaks, signed overflow, misaligned access, bad shifts.
$ clang -std=c23 -Wall -Wextra -Werror -g -O1 \
-fsanitize=address,undefined -fno-omit-frame-pointer -o app app.c
$ ./app
app.c:14:12: runtime error: signed integer overflow: 2147483647 + 1 cannot be
represented in type 'int'
# Threads: data races and lock-order inversions (mutually exclusive with ASan).
$ clang -std=c23 -g -fsanitize=thread -o app app.c
# Uninitialized reads (Clang only, and needs every dependency instrumented).
$ clang -std=c23 -g -fsanitize=memory -o app app.c
# No rebuild required, slower, catches leaks and invalid accesses:
$ valgrind --leak-check=full --track-origins=yes ./app
# Static analysis, no execution at all:
$ gcc -std=c23 -fanalyzer -c app.c
$ clang --analyze app.c
$ clang-tidy app.c -- -std=c23
Add -fsanitize-trap=undefined or -fno-sanitize-recover=all in CI so a violation fails the build instead
of printing a message the log swallows. And keep -D_FORTIFY_SOURCE=3 -fstack-protector-strong in release
builds — they are cheap and catch a real class of overflow at run time.
See Also
-
Control Flow — the
goto-cleanup pattern in isolation. -
Advanced Control Flow —
setjmp/longjmpand signal handlers, and why they are not exceptions. -
Dynamic Memory Allocation — the allocation defects in detail.
-
Build and Tooling — the full flag and tool catalogue.
-
C++: Error Handling — C++ adds exceptions, RAII cleanup and
std::expectedto these same strategies.
References
-
WG14 N3220 — the C23 working draft (§3.5.3 "undefined behavior", §4 "Conformance", Annex J "Portability issues" — J.2 is the definitive list of undefined behaviors).