Error handling
C does not have exceptions. There is no try/catch, no stack unwinding tied to error propagation, no destructor mechanism that runs implicitly on the way out. Errors in C are values: a function returns a code indicating success or failure, the caller checks the code, and if the call failed the caller must release any resources it has acquired and propagate the failure up. The mechanisms the language provides — return codes, the errno variable, the assert macro, setjmp/longjmp, the cleanup-label idiom — are the building blocks. The discipline of writing error-correct C is the principal practical concern after memory management, and the two are deeply entangled: most non-trivial error paths exist precisely to release the resources whose lifetime the function manages.
Return codes
The conventional mechanism is a status code returned alongside or instead of a result. Three patterns predominate.
Negative-on-error integer
A function that returns a non-negative value on success returns a negative value on error:
int read_byte(FILE *fp); /* returns 0–255 on success, -1 on EOF or error */
int b = read_byte(fp);
if (b < 0) {
/* handle */
}
The pattern is the convention for many <stdio.h> and POSIX functions. The principal difficulty is distinguishing among different errors: the caller knows that something failed but not what; the errno variable typically supplies the detail.
Null-on-error pointer
A function that returns a pointer returns NULL on error:
char *line = fgets(buf, sizeof buf, fp);
if (line == NULL) {
/* end of file or error */
}
The pattern is conventional for allocators, search functions, and constructors. The same difficulty applies: NULL indicates failure but carries no detail.
Boolean success with output parameters
A function that needs to communicate both success and a result writes the result to an output parameter and returns a boolean:
bool parse_int(const char *s, int *out) {
char *end;
long v = strtol(s, &end, 10);
if (end == s || *end != '\0' || v < INT_MIN || v > INT_MAX) {
return false;
}
*out = (int)v;
return true;
}
int n;
if (!parse_int(input, &n)) {
/* parse error */
}
The pattern keeps the call site explicit: the caller sees a bool and acts on it. The trade-off is the additional output parameter and the discipline of not reading *out on failure.
errno
errno is a thread-local integer that the standard library writes to indicate the cause of an error. It is declared in <errno.h> along with a set of macros for the error values:
#include <errno.h>
#include <stdio.h>
#include <string.h>
FILE *fp = fopen(path, "r");
if (!fp) {
fprintf(stderr, "%s: %s\n", path, strerror(errno));
return -1;
}
strerror (in <string.h>) converts an errno value to a textual description; perror prints <message>: <textual errno> to standard error. The standard error macros are few — EDOM, ERANGE, EILSEQ — but POSIX adds many (ENOENT, EACCES, EAGAIN, EINTR).
The discipline:
errnois set on error by standard-library functions; it is not reset on success. Readingerrnois meaningful only after a function that failed.- Conventionally, the failure-checking step reads
errnoimmediately, before any other library call: nearly every library function may modifyerrno, and intervening calls can clobber the value. errnois thread-local in C11; before C11 it was implementation-defined whether it was per-thread or shared.
assert
The <assert.h> macro assert(expr) evaluates expr; if the result is zero, the macro prints a diagnostic and calls abort:
#include <assert.h>
void process(const int *data, size_t n) {
assert(data != NULL);
assert(n > 0);
/* ... */
}
assert is for internal invariants: conditions that should hold by construction and whose failure indicates a bug in the program, not a recoverable error. The defining property is that disabling assertions (by defining NDEBUG before including <assert.h>) must not change the program’s correct behaviour.
The conventional discipline:
- Assertions check what the program is responsible for: invariants on internal state, preconditions of internal helpers, post-conditions whose violation indicates a bug.
- Assertions do not check what the environment is responsible for: file existence, network availability, user-supplied data validity. Those produce ordinary errors that the program handles and recovers from.
- An assertion that fails in production indicates a programming defect; ordinary errors do not.
C11 added _Static_assert (with the convenience macro static_assert from <assert.h>), which evaluates an expression at compile time and produces a diagnostic if it is zero:
static_assert(sizeof(int) >= 4, "int must be at least 32 bits");
static_assert(offsetof(struct s, field) == 0, "field must be first");
C23 promoted static_assert to a keyword. The construct is the conventional way to fail the build if a structural assumption is violated.
setjmp and longjmp
The <setjmp.h> library provides a non-local jump mechanism. setjmp(buf) saves the current execution state in buf; longjmp(buf, val) jumps back to the corresponding setjmp call, with setjmp returning val:
#include <setjmp.h>
static jmp_buf restore_point;
void parser(void) {
if (setjmp(restore_point) == 0) {
/* normal entry: parse the input */
parse();
} else {
/* arrived here from longjmp: parse failed */
cleanup();
}
}
void on_parse_error(void) {
/* invoked from deep inside the parser */
longjmp(restore_point, 1);
}
The mechanism resembles exception handling: a deeply nested function calls longjmp and control returns to the matching setjmp, bypassing the intermediate frames. The differences from genuine exceptions:
- The intermediate frames are not unwound: any resources held by them are leaked unless cleanup happens before
longjmp. - The construct is global state: a
jmp_bufis a static buffer that persists. - Local objects modified between the
setjmpand thelongjmpmay have indeterminate values after the jump (the standard requiresvolatile-qualified locals to retain their values; non-volatile locals may not). - The
setjmpcall may appear only in restricted positions (the controlling expression of anif,switch, comparison, orwhile).
setjmp/longjmp is rarely used in modern C: the cleanup discipline that exceptions provide is replicated more reliably with the goto-cleanup pattern. The conventional uses of setjmp are interpreter implementations that need exception-like control flow and signal-handling code that needs to escape from a signal handler back to a known point.
The cleanup-label idiom
The conventional mechanism for resource-acquisition discipline in C is the cleanup label: a single block of cleanup code at the end of a function, jumped to by goto from the error paths. Each acquisition step has a corresponding cleanup; the cleanup runs in reverse order from the failure point.
int do_work(const char *input_path, const char *output_path) {
int ret = -1;
FILE *in = NULL;
FILE *out = NULL;
char *buffer = NULL;
in = fopen(input_path, "r");
if (!in) {
fprintf(stderr, "open input: %s\n", strerror(errno));
goto cleanup;
}
out = fopen(output_path, "w");
if (!out) {
fprintf(stderr, "open output: %s\n", strerror(errno));
goto cleanup;
}
buffer = malloc(BUFFER_SIZE);
if (!buffer) {
fprintf(stderr, "out of memory\n");
goto cleanup;
}
if (transform(in, out, buffer) < 0) {
fprintf(stderr, "transform failed\n");
goto cleanup;
}
ret = 0;
cleanup:
free(buffer);
if (out) fclose(out);
if (in) fclose(in);
return ret;
}
The structure has several useful properties:
- Single cleanup point. All cleanup logic is in one block; no acquisition step has a cleanup branch hidden inside an
if. - Reverse-order cleanup. Resources are released in the reverse order of acquisition, which is conventionally the right order for dependent resources (close the output stream before flushing it, free the buffer after the streams that may be using it, and so on).
- Initial-
NULL/-1discipline. Every resource handle is initialised to a known “not held” value (typicallyNULLfor pointers,-1for file descriptors), so the cleanup can unconditionally check whether to release it. - Single return. The function has one
returnstatement; the result variable carries the success status.
The pattern requires goto — there is no other way to express “skip the rest of the body but run the cleanup” in standard C. The conventional position holds that this use of goto is the discipline, not its absence.
Modern variants
C23 introduced [[noreturn]] (and the older _Noreturn from C11) for functions like exit, abort, and _Exit that do not return. Marking a function [[noreturn]] informs the compiler that callers need not have a continuation path; the optimiser can elide post-call code, and the diagnostic about “missing return” after a call to such a function is suppressed.
Some compilers (GCC, Clang) provide __attribute__((cleanup(fn))) as an extension: a local variable so attributed has its address passed to fn when the variable goes out of scope. The mechanism produces something close to RAII for variables in C:
static void close_file(FILE **fp) { if (*fp) fclose(*fp); }
void process(void) {
__attribute__((cleanup(close_file))) FILE *fp = fopen("data", "r");
if (!fp) return;
/* fp is closed automatically on any path that leaves the scope */
}
The construction is non-portable and used principally in code that targets GCC or Clang specifically (the Linux kernel uses it in some subsystems). Portable C continues to use the cleanup-label idiom.
Defensive programming and the limits of error handling
C’s error-handling surface — return codes, errno, assert — is sufficient for most practical purposes, but it imposes a discipline that is easy to fail. The recurring sources of trouble:
- Unchecked returns. A function whose result is not examined reports a failure that nobody handles; the program continues with the failure undetected. Compilers can warn about ignored returns (
-Wunused-resultwith the__attribute__((warn_unused_result))annotation in GCC and Clang); applying the annotation to fallible functions catches a substantial fraction. - Errors converted to silent corruption. A failure that is detected but not propagated — handled by returning a default value rather than reporting the error — can produce silent data corruption that is far more difficult to diagnose than a loud failure.
- Non-uniform error conventions. A codebase with a mix of conventions (some functions return
-1on error, some returnNULL, some seterrno, some return a structure with an error field) is harder to use correctly than one with a single convention. Internal codebases typically converge on one form; the discipline is to enforce it consistently. - Missing cleanup on partial failure. A function that has acquired three resources and fails at the fourth must release all three; the conventional defence is the cleanup-label idiom, applied uniformly.
The combination — explicit return-code checking, the cleanup-label idiom, assert for invariants, and the contemporary sanitiser tooling for catching memory-related defects at runtime — produces error-handling that is verbose but robust. The verbosity is the trade for the language’s transparency; the robustness depends, as nearly everything in C does, on the programmer’s discipline.