Error Handling in C

October 11, 2026 • 6 min read

Error Handling in C
Table of contents

Part of the series:C Explained

Most modern languages throw exceptions: an error interrupts the normal flow and a handler far away catches it. C has no exceptions at all. Instead, every function that can fail tells you so through its return value, and you decide whether and how to handle it. That sounds primitive, and it is, but it is also why C code can be reasoned about precisely: nothing jumps out of nowhere.

The golden rule of C error handling1: check the return value of every function that can fail, and handle it immediately or pass it up.

Error codes as return values

The main function already showed the idea: return 0 means success and a non-zero value means failure, and that convention propagates everywhere. Many functions return an int status where 0 is success and negative values name the specific failure:

typedef enum {
    OK = 0,
    ERR_OPEN,
    ERR_READ,
    ERR_NOMEM
} Status;

Status load(const char *path) {
    FILE *f = fopen(path, "r");
    if (f == NULL) {
        return ERR_OPEN;
    }
    // ... use f ...
    fclose(f);
    return OK;
}

Callers test the result and decide:

Status s = load("data.txt");
if (s != OK) {
    fprintf(stderr, "load failed with code %d\n", s);
    return 1;
}

Two conventions to recognize in the wild: return 0 for success (borrowed from Unix) and -1/NULL for failure; or return a boolean-like 1/0. The enum version above scales best because each error has a name.

Pointers as failure signals

Functions that return a pointer signal failure with NULL. fopen, malloc, and strchr all do this, which is why the pointers post insisted on the != NULL check. NULL is the closest thing C has to “no result”:

FILE *f = fopen("notes.txt", "r");
if (f == NULL) {
    perror("fopen");
    return 1;
}

errno, perror, and strerror

When a system function like fopen, read, or malloc fails, it also records why in the global errno variable, declared in <errno.h>. Two helpers turn that number into a message:

  • perror("fopen") prints fopen: <message> to stderr, based on the current errno.
  • strerror(errno) returns the message string so you can format it yourself.
#include <errno.h>
#include <string.h>

FILE *f = fopen("missing.txt", "r");
if (f == NULL) {
    fprintf(stderr, "could not open file: %s\n", strerror(errno));
    return 1;
}

Print errors to stderr, not stdout. stderr is the standard error stream, and printing there keeps diagnostics out of the program’s normal output (so they still appear when output is redirected to a file). errno is set only when a function documents that it sets it, and only on failure, so set it to 0 before a call if you want to be sure the value you see came from that call.

assert: bugs vs errors

Some conditions are programming mistakes that should never happen, like malloc returning NULL in a teaching program or an index going negative. assert from <assert.h> checks such “this must be true” claims and aborts loudly if they are false:

#include <assert.h>

int divide(int a, int b) {
    assert(b != 0);   // a caller bug, not a runtime user error
    return a / b;
}

When the assertion fails, the program prints the file and line and stops. Assertions are for invariants that separate a broken program from valid input. They are compiled out when you define NDEBUG, so never put code with side effects inside assert, and never use them for things a user can trigger (like bad input) because not all builds check them.

exit and atexit

exit(status) ends the whole program immediately, running cleanup handlers registered with atexit on the way out:

#include <stdlib.h>

FILE *f = fopen("out.txt", "w");
if (f == NULL) {
    fprintf(stderr, "cannot write output\n");
    exit(EXIT_FAILURE);   // 1
}
// ...
exit(EXIT_SUCCESS);        // 0

EXIT_SUCCESS and EXIT_FAILURE are the portable values for return 0 and return 1. Prefer returning from main when you can, and reach for exit only when the error is unrecoverable deep inside nested calls. atexit(fn) registers a function to run when the program exits, useful for flushing files or freeing global resources.

setjmp and longjmp

C does have a low-level escape hatch that mimics a non-local jump. setjmp saves the current point, and longjmp jumps back to it from anywhere, which some libraries use to simulate exceptions:

#include <setjmp.h>

jmp_buf env;

void deep(void) {
    longjmp(env, 1);   // jump back to where setjmp was called
}

int main(void) {
    if (setjmp(env) == 0) {
        deep();
    } else {
        printf("recovered\n");
    }
    return 0;
}

Reach for it only when you understand it fully: longjmp skips stack cleanup, so any local automatic variables and unfinished cleanup in the frames it skips are lost, and resources leak. It is powerful and sharp; most code should not use it.

Cleaning up: the goto idiom

Because you must free and close things by hand, code that grows progressively and then fails leaves a trail of resources to release. The idiomatic C solution is a single cleanup path at the bottom and goto to reach it:

int process(const char *path) {
    int result = -1;
    FILE *f = NULL;
    char *buffer = NULL;

    f = fopen(path, "r");
    if (f == NULL) {
        goto cleanup;
    }

    buffer = malloc(1024);
    if (buffer == NULL) {
        goto cleanup;
    }

    // ... read and process ...

    result = 0;

cleanup:
    free(buffer);
    if (f != NULL) {
        fclose(f);
    }
    return result;
}

Each failure jumps to the one place that releases everything; free(NULL) and skipping the close when f is NULL are both safe, so the cleanup path needs no error tracking of its own. This backward goto is one of the few uses the C community universally approves.

Defensive programming

The mindset matters more than any single function: assume input is hostile, assume calls can fail, and handle the first error at the point it happens. Validate arguments at the top of a function, check every allocation, close every file, and never ignore a return value. Compile with -Wall -Wextra and fix warnings, because the compiler catches a surprising number of error-handling mistakes, such as a return value that is discarded.

The big picture

C turns error handling into explicit control flow. Functions return status codes or NULL; errno/perror/strerror explain system failures; assert catches programmer bugs while exit handles the unrecoverable; setjmp/longjmp exist but are rarely the right tool; and a shared cleanup: path with goto keeps resources from leaking on failure. Every one of these choices can be wrong, which is exactly why the next post exists: testing, so you find the failures before your users do.

Footnotes

  1. C error handling - Wikipedia