Skip to main content
C intermediate Lesson 16 of 23

Error Handling in C

Learn errno, perror, strerror, setjmp/longjmp, and defensive programming patterns for robust C programs.

Return Value Conventions

C has no exceptions. Error handling is explicit: functions signal failure through their return value, and callers must check it. This makes error paths visible in the code, which is both a strength (no hidden control flow) and a burden (you must remember to check). Consistency within a codebase matters — pick one convention and stick with it.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* Pattern 1: return NULL on error — natural for functions that allocate or find */
char *duplicate_upper(const char *s) {
    if (!s) return NULL;   /* validate input first */

    char *result = malloc(strlen(s) + 1);
    if (!result) return NULL;  /* malloc failed — propagate NULL to caller */

    for (int i = 0; s[i]; i++) {
        result[i] = (char)toupper((unsigned char)s[i]);
    }
    result[strlen(s)] = '\0';
    return result;
}

/* Pattern 2: return 0 on success, -1 on error — natural for operations */
int read_int_from_file(const char *filename, int *out) {
    FILE *fp = fopen(filename, "r");
    if (!fp) return -1;

    int ok = fscanf(fp, "%d", out) == 1;
    fclose(fp);
    return ok ? 0 : -1;
}

/* Pattern 3: return an error code enum — best for complex APIs with many error types */
typedef enum {
    ERR_OK = 0,
    ERR_NULL_ARG,
    ERR_OUT_OF_MEMORY,
    ERR_IO,
    ERR_INVALID_DATA,
} ErrorCode;

const char *error_message(ErrorCode e) {
    switch (e) {
        case ERR_OK:            return "success";
        case ERR_NULL_ARG:      return "null argument";
        case ERR_OUT_OF_MEMORY: return "out of memory";
        case ERR_IO:            return "I/O error";
        case ERR_INVALID_DATA:  return "invalid data";
        default:                return "unknown error";
    }
}

int main(void) {
    char *upper = duplicate_upper("hello");
    if (upper) {
        printf("%s\n", upper);
        free(upper);
    }
    return 0;
}

errno, perror, and strerror

Standard library functions set the global variable errno when they fail. errno is a thread-local integer that contains a code describing the most recent error. Always check it immediately after a failing call — the next system call will overwrite it.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>

int main(void) {
    /* Open a non-existent file to trigger an error */
    FILE *fp = fopen("/nonexistent/path/file.txt", "r");
    if (!fp) {
        /* errno is now ENOENT (no such file or directory) */
        printf("errno = %d\n", errno);

        /* perror: prints "prefix: human-readable message\n" to stderr */
        perror("fopen failed");

        /* strerror: returns the error string — useful for custom formatting */
        printf("Error: %s\n", strerror(errno));
    }

    /* Reset errno before a call whose error you want to detect */
    errno = 0;
    long val = strtol("not_a_number", NULL, 10);
    if (errno != 0) {
        perror("strtol");
    } else {
        printf("val = %ld\n", val);  /* 0 — partial conversion */
    }

    /* Check errno after math functions for domain errors */
    #include <math.h>
    errno = 0;
    double result = sqrt(-1.0);
    if (errno == EDOM) {
        fprintf(stderr, "sqrt: domain error\n");
    }

    return 0;
}

Important rules for errno:

  1. Check it immediately after the failing call — the next system call will overwrite it
  2. Set errno = 0 before calls whose failure you want to distinguish from “no error”
  3. errno is thread-local in POSIX — safe to use in multi-threaded programs

Common errno values:

ConstantValueMeaning
EPERM1Operation not permitted
ENOENT2No such file or directory
ENOMEM12Out of memory
EACCES13Permission denied
EEXIST17File exists
EINVAL22Invalid argument
ENOSPC28No space left on device

Defensive Programming

Defensive programming means validating your assumptions explicitly, so bugs surface immediately at the point of the mistake rather than silently corrupting data and crashing somewhere unrelated later.

#include <assert.h>
#include <stddef.h>

/* assert: terminates with a message if condition is false.
   Use for programming errors — conditions that should NEVER be false in correct code. */
double safe_divide(double a, double b) {
    assert(b != 0.0 && "divisor must not be zero");
    return a / b;
}

/* For runtime errors (user input, files, network) use proper error returns, not assert */
int parse_age(const char *s, int *out_age) {
    if (!s || !out_age) return -1;  /* null check — never trust callers */

    char *end;
    errno = 0;
    long age = strtol(s, &end, 10);

    if (errno != 0)          return -1;  /* overflow or underflow */
    if (end == s)            return -1;  /* no digits found */
    if (*end != '\0')        return -1;  /* trailing garbage like "25abc" */
    if (age < 0 || age > 150) return -1;  /* domain check */

    *out_age = (int)age;
    return 0;
}

/* Compile-time assertions catch platform assumptions at build time */
#include <stdint.h>
_Static_assert(sizeof(int) == 4, "int must be 4 bytes on this platform");
_Static_assert(sizeof(void*) >= 4, "pointer must be at least 4 bytes");

Error Propagation with goto Cleanup

When a function acquires multiple resources and any step can fail, the goto cleanup pattern provides a clean single exit point without duplicating cleanup code at every return path. This is the most widely accepted use of goto in C.

#include <stdio.h>
#include <stdlib.h>

typedef struct { int *data; size_t n; } IntArray;

int process_file(const char *filename, IntArray *out) {
    FILE  *fp  = NULL;
    int   *buf = NULL;
    int    rc  = -1;

    fp = fopen(filename, "r");
    if (!fp) { perror("fopen"); goto cleanup; }

    int count;
    if (fscanf(fp, "%d", &count) != 1 || count <= 0) {
        fprintf(stderr, "Invalid count\n");
        goto cleanup;
    }

    buf = malloc((size_t)count * sizeof(int));
    if (!buf) { perror("malloc"); goto cleanup; }

    for (int i = 0; i < count; i++) {
        if (fscanf(fp, "%d", &buf[i]) != 1) {
            fprintf(stderr, "Read error at index %d\n", i);
            goto cleanup;
        }
    }

    out->data = buf;
    out->n    = (size_t)count;
    buf = NULL;   /* transfer ownership — don't free in cleanup */
    rc  = 0;      /* success */

cleanup:
    free(buf);          /* free(NULL) is safe — a no-op */
    if (fp) fclose(fp);
    return rc;
}

setjmp / longjmp

setjmp saves the current execution context (stack pointer, registers) and returns 0. longjmp restores that context and makes setjmp return a non-zero value, effectively jumping back in time to the setjmp call site. This implements non-local jumps across function call frames.

#include <stdio.h>
#include <setjmp.h>

static jmp_buf error_jump;

void deep_function(int level) {
    printf("Entering level %d\n", level);
    if (level == 0) {
        fprintf(stderr, "Fatal error at level 0!\n");
        longjmp(error_jump, 1);   /* jump back to setjmp, which returns 1 */
    }
    deep_function(level - 1);
    printf("Returning from level %d\n", level);   /* never reached if longjmp fires */
}

int main(void) {
    int err = setjmp(error_jump);   /* returns 0 on first call */

    if (err == 0) {
        deep_function(3);
        printf("deep_function completed normally\n");
    } else {
        fprintf(stderr, "Caught error (code %d) — aborting\n", err);
        return 1;
    }

    return 0;
}

Caveats:

  • Variables modified between setjmp and longjmp must be volatile to be reliably visible after the jump
  • All resources allocated between setjmp and longjmp (file handles, heap memory) are leaked — there is no automatic cleanup
  • Do not longjmp out of a signal handler (undefined behavior)
  • Never use longjmp to jump into a function (only out)

For most code, explicit error returns are cleaner and safer than setjmp/longjmp.

Frequently Asked Questions

Why doesn't C have exceptions?
C was designed for simplicity and minimal runtime overhead. Exceptions require stack unwinding machinery that adds cost and complexity. C's error handling is explicit — you check return values and propagate errors manually.
Is setjmp/longjmp a good substitute for exceptions?
No. setjmp/longjmp is dangerous — it skips destructors (C has none, but it skips cleanup code), and C doesn't automatically free resources when jumping. Use it only as a last resort for unrecoverable error scenarios like a script interpreter's panic handler.
What should a function return to indicate an error?
Common conventions: return NULL for pointer-returning functions, return -1 or a negative value for int-returning functions, return a specific error code enum, or use a boolean + output parameter pattern. Be consistent within a codebase.