Error Handling in C
The Challenge
C has no exceptions, no try/catch, and no Result type. Error handling is manual, explicit, and verbose. Every system call and library function can fail, and it is the programmer's responsibility to check.
This explicitness is both C's weakness and its strength. Nothing is hidden. Every error path is visible in the source code.
errno: The Global Error Variable
When a system call or C library function fails, it sets the global variable errno to a value indicating the error. errno is declared in <errno.h>.
#include <stdio.h>
#include <errno.h>
#include <string.h>
int main(void) {
FILE *f = fopen("/nonexistent/path/file.txt", "r");
if (f == NULL) {
printf("errno = %d\n", errno);
printf("Error: %s\n", strerror(errno));
return 1;
}
fclose(f);
return 0;
}
errno = 2
Error: No such file or directory
Important rules for errno:
- Only check
errnoafter a function that is documented to set it, and only when the function indicates failure. errnois never reset to 0 by a successful call. A nonzeroerrnoafter a successful call is meaningless.- Set
errno = 0before calling a function if you need to distinguish "no error" from a stale value.
perror & strerror
perror prints a human-readable error message to stderr:
FILE *f = fopen("missing.txt", "r");
if (!f) {
perror("fopen");
return 1;
}
fopen: No such file or directory
strerror returns the error string without printing it, so you can format it yourself:
if (!f) {
fprintf(stderr, "Could not open config file: %s\n", strerror(errno));
return 1;
}
perror is convenient for quick debugging. strerror is better when you need to include additional context.
The Return Value Pattern
The standard pattern for C error handling: check the return value, then handle the error.
Functions Returning Pointers
Functions that return pointers (like fopen, malloc) return NULL on failure:
char *buf = malloc(size);
if (buf == NULL) {
fprintf(stderr, "Out of memory (requested %zu bytes)\n", size);
exit(EXIT_FAILURE);
}
Functions Returning Integers
POSIX functions typically return -1 on error and set errno:
int fd = open("data.bin", O_RDONLY);
if (fd < 0) {
perror("open");
return -1;
}
Functions Returning Size
fread and fwrite return the number of items transferred:
size_t n = fread(buf, sizeof(int), count, f);
if (n != count) {
if (feof(f)) {
fprintf(stderr, "Unexpected end of file\n");
} else if (ferror(f)) {
perror("fread");
}
}
The Standard Error Handling Template
if (result < 0) {
perror("operation_name");
/* clean up resources */
exit(EXIT_FAILURE); /* or return an error code */
}
In a library, do not call exit. Return an error code and let the caller decide:
int db_open(const char *path, struct Database **out) {
int fd = open(path, O_RDWR);
if (fd < 0) {
return -1; /* caller checks errno */
}
struct Database *db = malloc(sizeof(*db));
if (!db) {
close(fd);
return -1;
}
db->fd = fd;
*out = db;
return 0;
}
goto for Cleanup Chains
When a function acquires multiple resources, each failure point must release all previously acquired resources. The goto cleanup pattern keeps this manageable:
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
int process_files(const char *input, const char *output) {
int result = -1;
int in_fd = -1;
int out_fd = -1;
char *buffer = NULL;
in_fd = open(input, O_RDONLY);
if (in_fd < 0) {
perror("open input");
goto cleanup;
}
out_fd = open(output, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (out_fd < 0) {
perror("open output");
goto cleanup;
}
buffer = malloc(8192);
if (!buffer) {
perror("malloc");
goto cleanup;
}
ssize_t n;
while ((n = read(in_fd, buffer, 8192)) > 0) {
if (write(out_fd, buffer, n) != n) {
perror("write");
goto cleanup;
}
}
if (n < 0) {
perror("read");
goto cleanup;
}
result = 0;
cleanup:
free(buffer);
if (out_fd >= 0) close(out_fd);
if (in_fd >= 0) close(in_fd);
return result;
}
This pattern is used extensively in the Linux kernel and other large C codebases. The cleanup section releases resources in reverse order of acquisition. Each resource is initialized to a safe value (NULL, -1) so the cleanup section can safely check before releasing.
Error Codes in Libraries
Well-designed C libraries define their own error codes rather than relying solely on errno:
/* mylib.h */
enum MyError {
MYLIB_OK = 0,
MYLIB_ERR_NOMEM = -1,
MYLIB_ERR_INVALID = -2,
MYLIB_ERR_IO = -3,
MYLIB_ERR_TIMEOUT = -4,
};
const char *mylib_strerror(int err);
/* mylib.c */
const char *mylib_strerror(int err) {
switch (err) {
case MYLIB_OK: return "Success";
case MYLIB_ERR_NOMEM: return "Out of memory";
case MYLIB_ERR_INVALID: return "Invalid argument";
case MYLIB_ERR_IO: return "I/O error";
case MYLIB_ERR_TIMEOUT: return "Operation timed out";
default: return "Unknown error";
}
}
Usage:
int err = mylib_connect(host, port, &conn);
if (err != MYLIB_OK) {
fprintf(stderr, "Connection failed: %s\n", mylib_strerror(err));
return 1;
}
setjmp & longjmp: Non-Local Jumps
setjmp and longjmp provide a mechanism similar to exceptions. setjmp saves the execution context; longjmp restores it, jumping back to the setjmp call.
#include <stdio.h>
#include <setjmp.h>
#include <stdlib.h>
static jmp_buf error_buf;
void risky_operation(int value) {
if (value < 0) {
longjmp(error_buf, 1); /* "throw" an error */
}
printf("Processing: %d\n", value);
}
int main(void) {
int status = setjmp(error_buf);
if (status != 0) {
/* We jumped here from longjmp */
fprintf(stderr, "Error caught! status=%d\n", status);
return 1;
}
/* Normal execution */
risky_operation(10);
risky_operation(20);
risky_operation(-5); /* triggers longjmp */
risky_operation(30); /* never reached */
return 0;
}
Processing: 10
Processing: 20
Error caught! status=1
Why setjmp/longjmp Is Rarely Used
- Resources acquired between
setjmpandlongjmpare not cleaned up. There are no destructors. - Local variables modified between
setjmpandlongjmphave indeterminate values unless declaredvolatile. - The code is hard to follow. The control flow is invisible.
- It breaks the
gotocleanup pattern because it skips the cleanup section entirely.
Some C libraries use setjmp/longjmp internally (notably Lua and some PNG/JPEG libraries), but most C code avoids it in favor of explicit error codes.
Real-World Example: Robust File Processing
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
struct Config {
char hostname[256];
int port;
int max_connections;
};
int parse_config(const char *path, struct Config *cfg) {
FILE *f = fopen(path, "r");
if (!f) {
fprintf(stderr, "Cannot open %s: %s\n", path, strerror(errno));
return -1;
}
/* Set defaults */
memset(cfg, 0, sizeof(*cfg));
cfg->port = 8080;
cfg->max_connections = 100;
char line[512];
int lineno = 0;
while (fgets(line, sizeof(line), f)) {
lineno++;
/* Skip comments and empty lines */
if (line[0] == '#' || line[0] == '\n') continue;
char key[64], value[256];
if (sscanf(line, "%63s %255s", key, value) != 2) {
fprintf(stderr, "%s:%d: malformed line\n", path, lineno);
continue; /* skip bad lines, do not abort */
}
if (strcmp(key, "hostname") == 0) {
strncpy(cfg->hostname, value, sizeof(cfg->hostname) - 1);
} else if (strcmp(key, "port") == 0) {
char *end;
long p = strtol(value, &end, 10);
if (*end != '\0' || p <= 0 || p > 65535) {
fprintf(stderr, "%s:%d: invalid port: %s\n", path, lineno, value);
fclose(f);
return -1;
}
cfg->port = (int)p;
} else if (strcmp(key, "max_connections") == 0) {
char *end;
long mc = strtol(value, &end, 10);
if (*end != '\0' || mc <= 0) {
fprintf(stderr, "%s:%d: invalid max_connections: %s\n",
path, lineno, value);
fclose(f);
return -1;
}
cfg->max_connections = (int)mc;
} else {
fprintf(stderr, "%s:%d: unknown key: %s\n", path, lineno, key);
}
}
if (ferror(f)) {
perror("fgets");
fclose(f);
return -1;
}
fclose(f);
if (cfg->hostname[0] == '\0') {
fprintf(stderr, "%s: missing required 'hostname'\n", path);
return -1;
}
return 0;
}
int main(void) {
struct Config cfg;
if (parse_config("server.conf", &cfg) == 0) {
printf("Host: %s, Port: %d, Max: %d\n",
cfg.hostname, cfg.port, cfg.max_connections);
}
return 0;
}
This example demonstrates multiple error handling strategies: checking fopen, validating parsed values, reporting line numbers, using ferror after the read loop, and validating required fields after parsing.
Common Pitfalls
- Checking errno after a successful call.
errnoretains its value from the last failure. Only check it when the function indicates failure. - Not checking return values at all. The most common C bug pattern. Every I/O call, every allocation, every system call can fail.
- Inconsistent error paths. If one error path frees resources but another does not, you have a leak. Use the
gotocleanup pattern to centralize cleanup. - Using perror after calling another function. If you call
fprintfor any other function between the failure andperror,errnomay be overwritten. Saveerrnoimmediately or callperrorfirst. - Mixing error styles. Some functions return -1 and set errno; others return 0 for success and nonzero for error; others return NULL. Know which convention each function uses.
- Using setjmp/longjmp without understanding the consequences. Resources are not cleaned up, and local variable values may be indeterminate.
Key Takeaways
- C error handling is explicit: check return values, consult
errno, and handle every failure. - Use
perrorfor quick error messages andstrerrorwhen you need more context. - The
gotocleanup pattern is the standard C idiom for releasing resources on error. It is used throughout the Linux kernel. - Well-designed libraries define their own error codes and provide a
strerror-like function. setjmp/longjmpprovides exception-like control flow but is dangerous because it skips cleanup. Most C code avoids it.- Verbose error handling is a feature, not a bug. Every error path is visible, auditable, and testable.