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 (INT_MAX + 1)

Undefined. Not wrapping — the optimizer may assume it cannot occur.

Division or remainder by zero

Undefined. No exception, no signal guarantee.

INT_MIN / -1, abs(INT_MIN)

Undefined — the true result is not representable.

Shift by a negative amount, or by ≥ the operand’s width

Undefined. 1u << 32 is not 0.

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 <fenv.h> flags raised.

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 < 1u trap 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 printf conversion that does not match its argument, a va_arg type mismatch, a wrong-signature callback called through a converted function pointer. All undefined, none checked at run time. -Wformat covers the printf case.

  • Invalidation: keeping a pointer or iterator past the point where the object it names may move — notably after realloc (the old pointer is dead), after free, when a static buffer 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 goto-cleanup pattern prevents.

Integer-index drift

A counter that grows unbounded until it overflows. Use size_t, and check.

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 *_message function. Do not overload int with 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 — -1 for a size_t is SIZE_MAX.

errno Discipline

Recapping the rules from Standard Library Overview, because getting them wrong is so common:

  1. errno is meaningful only after a function documented to set it has failed.

  2. Read (or save) it immediately — printf may change it.

  3. Set errno = 0 before calls that return a valid value on failure (strtol, the maths functions).

  4. 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

assert(cond)

A programming error — an invariant your own code must uphold. Compiled out under -DNDEBUG, so it must have no side effects and must never validate input.

if (!cond) return ERROR;

Input or environment validation — anything that can legitimately fail at run time. Must survive a release build.

static_assert(cond)

A compile-time fact: type widths, struct layout, configuration sanity.

unreachable()

C23, <stddef.h>: marks a path that cannot be reached. Reaching it is UB, so use it only where you have proved the impossibility — it removes checks.

#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