C has no exceptions

In Python, Java or C#, an operation that cannot be carried out throws an exception: normal execution stops, and the runtime searches up the call stack for a handler. C has no such mechanism. A C function that fails simply returns, and the only sign of trouble is a value the caller has to check. If the caller does not check, the program carries on with whatever it got — an uninitialized number, a NULL pointer, half a file.

Every library and code base therefore picks conventions, and the C standard library itself uses several:

Convention Failure looks like Examples
Status code non-zero (or negative) int remove, fclose (EOF), most POSIX functions (-1)
Pointer result NULL fopen, malloc, fgets, strchr (not found)
Special value in the normal range EOF, -1, a count that is too low getchar, scanf (fewer items than asked), printf (negative)
Side channel errno set to an error number fopen, strtol (out of range), malloc on most systems

Reporting errors through return values

Special values in the result

The simplest convention reserves a value that a valid result can never have. A meter reading in kWh is never negative, so -1 can mean "unknown meter". It works as long as every caller knows the rule and the reserved value really is impossible — for a temperature in degrees Celsius, -1 would be a perfectly normal value.

#include <stdio.h>

/* Returns the meter's reading in kWh, or -1 if the meter is unknown. */
static int last_reading(int meter)
{
    switch (meter) {
    case 30004: return 412;
    case 30007: return 1500;
    default:    return -1;          /* sentinel: not a valid reading */
    }
}

int main(void)
{
    int meters[] = {30004, 30099, 30007};

    for (int i = 0; i < 3; i++) {
        int kwh = last_reading(meters[i]);
        if (kwh < 0) {
            printf("M-%d: unknown meter, skipped\n", meters[i]);
            continue;
        }
        printf("M-%d: %d kWh\n", meters[i], kwh);
    }
    return 0;
}
M-30004: 412 kWh
M-30099: unknown meter, skipped
M-30007: 1500 kWh

Error codes

An error code is a number a function returns to say whether it succeeded and, if not, what went wrong. The common C style is 0 for success and a non-zero value for each kind of failure, often named with an enum so that callers can tell the cases apart and print a useful message. The real result then has to travel another way — usually through a pointer the caller passes in.

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

enum parse_status { PARSE_OK = 0, PARSE_EMPTY, PARSE_NOT_A_NUMBER, PARSE_NEGATIVE };

/* Reads a whole number of kWh from text. The result goes into *kwh;
   the return value only says whether that worked. */
static enum parse_status parse_kwh(const char *text, long *kwh)
{
    if (text[0] == '\0') {
        return PARSE_EMPTY;
    }
    char *end;
    long value = strtol(text, &end, 10);
    if (*end != '\0') {
        return PARSE_NOT_A_NUMBER;
    }
    if (value < 0) {
        return PARSE_NEGATIVE;
    }
    *kwh = value;                   /* only written on success */
    return PARSE_OK;
}

static const char *status_text(enum parse_status status)
{
    switch (status) {
    case PARSE_OK:           return "ok";
    case PARSE_EMPTY:        return "empty field";
    case PARSE_NOT_A_NUMBER: return "not a whole number";
    case PARSE_NEGATIVE:     return "negative reading";
    }
    return "unknown status";
}

int main(void)
{
    const char *fields[] = {"350", "", "12x4", "-20"};

    for (int i = 0; i < 4; i++) {
        long kwh = 0;
        enum parse_status status = parse_kwh(fields[i], &kwh);
        if (status == PARSE_OK) {
            printf("\"%s\" -> %ld kWh\n", fields[i], kwh);
        } else {
            printf("\"%s\" -> error %d (%s)\n", fields[i], status, status_text(status));
        }
    }
    return 0;
}
"350" -> 350 kWh
"" -> error 1 (empty field)
"12x4" -> error 2 (not a whole number)
"-20" -> error 3 (negative reading)

Zero for success looks backward at first (zero is "false" in an if), but it leaves every non-zero value free for a different error, and it matches the exit status convention of the shell (see below).

Output parameters

parse_kwh above returns its status and delivers the number through long *kwh. A pointer parameter that a function writes its result into is an output parameter; it is also how a C function returns more than one value. How pointers make this possible is explained in Pointers. Two habits keep output parameters safe:

  • Write the output only on success. The caller's variable then keeps its old (initialized) value after a failure, instead of half-parsed garbage.
  • Document whether the pointer may be NULL. A function that dereferences a NULL output pointer crashes.

The compiler can insist on a check

Ignoring a status is the most common error-handling bug in C, and SEI CERT C rule ERR33-C, "Detect and handle standard library errors", exists for exactly this reason. C23 lets a function demand attention with the [[nodiscard]] attribute; GCC and Clang have long offered the same check as __attribute__((warn_unused_result)). With -Werror, a caller that throws the status away no longer compiles. This example is compiled with Clang in C23 mode:

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

/* C23: callers must not ignore the status. */
[[nodiscard]] static int parse_kwh(const char *text, long *kwh)
{
    char *end;
    *kwh = strtol(text, &end, 10);
    return (end != text && *end == '\0') ? 0 : -1;
}

int main(void)
{
    long kwh = 0;
    parse_kwh("12x4", &kwh);            /* status thrown away */
    printf("%ld kWh\n", kwh);
    return 0;
}
example.c:15:5: error: ignoring return value of function declared with 'nodiscard' attribute [-Werror,-Wunused-result]

NULL, EOF and the standard library

Before using a standard function, look up what it returns on failure; it differs per function, and some of the differences are traps.

Function On failure Watch out for
fopen NULL, errno set (POSIX) check before the first read or write
malloc, calloc, realloc NULL p = realloc(p, n) loses the old block on failure (see Dynamic Memory)
fgets NULL at end of file or on error use feof/ferror to tell them apart
getchar, fgetc EOF (a negative int) store the result in an int, never a char
scanf, sscanf fewer items than requested, or EOF compare with the exact number you expect
fclose EOF buffered data is written here; a full disk shows up now
strtol, strtoul no special value check errno and the end pointer (below)
atoi no way to tell returns 0 for "abc"; avoid it for input

errno: the error number

Many library functions say that they failed through their return value and why through errno, an integer declared in <errno.h> that a failing function sets to an error number such as ENOENT (no such file or directory). Since C11 each thread has its own errno, so threads do not overwrite each other's value. strerror from <string.h> turns a number into a message:

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

int main(void)
{
    int codes[] = {ENOENT, EACCES, ENOMEM, EINVAL};
    for (int i = 0; i < 4; i++) {
        printf("%2d  %s\n", codes[i], strerror(codes[i]));
    }
    return 0;
}
 2  No such file or directory
13  Permission denied
12  Cannot allocate memory
22  Invalid argument

The C standard itself defines only three values (EDOM, EILSEQ and ERANGE); POSIX adds the rest. The numbers and the exact wording come from the C library — the output above is glibc's on Linux — so compare against the names (errno == ENOENT), never against the numbers or the text.

perror and saving errno

perror(s) writes s, a colon, a space and the message for the current errno to standard error. It is the quickest way to report a failed system or library call. But errno is only a global variable, and any later library call — including perror and printf themselves — may change it, even when that call succeeds. On this machine, perror left a different value behind:

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

int main(void)
{
    FILE *file = fopen("readings-2026-10.csv", "r");
    if (file == NULL) {
        int saved = errno;
        printf("before perror: errno=%d\n", errno);
        perror("readings-2026-10.csv");
        printf("after perror:  errno=%d\n", errno);
        printf("saved copy:    %d (%s)\n", saved, strerror(saved));
    }
    return 0;
}
before perror: errno=2
readings-2026-10.csv: No such file or directory
after perror:  errno=22
saved copy:    2 (No such file or directory)

The value 22 (EINVAL) is a leftover from glibc's work inside perror, not a new error: perror succeeded. The rule is simple — copy errno into a local variable immediately after the failing call, before anything else runs.

Rules for reading errno

  • Look at errno only after the return value says the call failed. A successful call may leave any old value in it.
  • No library function ever sets errno to 0. For a function whose return value cannot signal failure, such as strtol, set errno = 0 yourself before the call and check it afterward.
  • Check the documentation per function: the C standard does not require fopen or malloc to set errno; POSIX and glibc do.

strtol shows the full protocol: errno reports a number out of range, and the end pointer reports text that is not a number at all.

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

/* strtol's full error protocol: set errno to 0, call, then check
   errno, the end pointer and the remaining text. */
static int to_long(const char *text, long *out)
{
    char *end;
    errno = 0;
    long value = strtol(text, &end, 10);
    if (errno == ERANGE) {
        printf("%-22s out of range: %s\n", text, strerror(errno));
        return -1;
    }
    if (end == text || *end != '\0') {
        printf("%-22s not a whole number\n", text);
        return -1;
    }
    *out = value;
    return 0;
}

int main(void)
{
    const char *inputs[] = {"3504000000", "99999999999999999999", "12x4", ""};
    for (int i = 0; i < 4; i++) {
        long value;
        if (to_long(inputs[i], &value) == 0) {
            printf("%-22s ok: %ld\n", inputs[i], value);
        }
    }
    return 0;
}
3504000000             ok: 3504000000
99999999999999999999   out of range: Numerical result out of range
12x4                   not a whole number
                       not a whole number

3,504,000,000 fits because long is 64 bits on this machine; on systems where long is 32 bits (64-bit Windows, many microcontrollers) the first line would be out of range as well. Integer sizes are covered in Variables, Types and Integers.

Validating input: BUG-1060 in C

Error handling starts with deciding what counts as an error. The Kestrel Home pilot dongle from Smart Meters and the P1 Port is built by an outside manufacturer and sold under Kestrel's name; its firmware is the manufacturer's C code. BUG-1060 (24 September 2026) was a parser that accepted exactly four CRC digits, while one meter sent three: B9F. The tempting fix in C is to let strtoul convert "whatever is there". It accepts far more than hexadecimal digits:

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

int main(void)
{
    /* The CRC field after the '!' at the end of a telegram. */
    const char *fields[] = {"F1F4", "B9F", " 0xB9F", "-1", "F1F4F", ""};

    for (int i = 0; i < 6; i++) {
        char *end;
        unsigned long crc = strtoul(fields[i], &end, 16);
        int width = printf("\"%s\"", fields[i]);
        printf("%*s-> %lX (used %td characters)\n",
               10 - width, "", crc, end - fields[i]);
    }
    return 0;
}
"F1F4"    -> F1F4 (used 4 characters)
"B9F"     -> B9F (used 3 characters)
" 0xB9F"  -> B9F (used 6 characters)
"-1"      -> FFFFFFFFFFFFFFFF (used 2 characters)
"F1F4F"   -> F1F4F (used 5 characters)
""        -> 0 (used 0 characters)

Leading spaces, a 0x prefix and a minus sign are all accepted (the minus is applied by wrapping around, which turns -1 into the largest unsigned long); five digits are not a CRC-16 at all, and an empty field becomes a "valid" 0 unless the caller checks the end pointer. A parser for device data should say exactly what it accepts and reject the rest with an error code:

#include <stdint.h>
#include <stdio.h>

/* Value of one hexadecimal digit, or -1 if c is not one. */
static int hex_value(char c)
{
    if (c >= '0' && c <= '9') return c - '0';
    if (c >= 'A' && c <= 'F') return c - 'A' + 10;
    if (c >= 'a' && c <= 'f') return c - 'a' + 10;
    return -1;
}

/* Parses the CRC field of a P1 telegram: 1 to 4 hex digits, then
   exactly "\r\n". Returns 0 and stores the CRC on success, -1 otherwise. */
static int parse_crc_field(const char *field, uint16_t *crc)
{
    unsigned value = 0;
    int digits = 0;

    while (hex_value(field[digits]) >= 0) {
        if (digits == 4) {
            return -1;                      /* five or more digits */
        }
        value = value * 16 + (unsigned)hex_value(field[digits]);
        digits++;
    }
    if (digits == 0 || field[digits] != '\r' || field[digits + 1] != '\n'
        || field[digits + 2] != '\0') {
        return -1;
    }
    *crc = (uint16_t)value;
    return 0;
}

int main(void)
{
    const char *fields[] = {"F1F4\r\n", "B9F\r\n", " 0xB9F\r\n",
                            "-1\r\n", "F1F4F\r\n", "\r\n"};

    for (int i = 0; i < 6; i++) {
        uint16_t crc;
        if (parse_crc_field(fields[i], &crc) == 0) {
            printf("field %d: CRC %04X\n", i + 1, (unsigned)crc);
        } else {
            printf("field %d: rejected\n", i + 1);
        }
    }
    return 0;
}
field 1: CRC F1F4
field 2: CRC 0B9F
field 3: rejected
field 4: rejected
field 5: rejected
field 6: rejected

One to four digits, then exactly "\r\n": the short CRC from BUG-1060 passes, and everything that cannot be verified is refused. Code like this is also the natural target for fuzzing, because it is the part of the device that reads untrusted input. How strings and buffers work underneath is on Strings and Buffer Overflows.

Exit status: telling the shell

A C program reports its overall result to whoever started it through the return value of main (or the argument of exit()). The operating system keeps the low 8 bits, and the shell makes them available as the exit status in $?. <stdlib.h> defines two portable values, EXIT_SUCCESS and EXIT_FAILURE; on Linux they are 0 and 1. A failing program should write a message to standard error and return a non-zero status:

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

int main(void)
{
    const char *path = "readings-2026-10.csv";
    FILE *file = fopen(path, "r");
    if (file == NULL) {
        perror(path);
        return EXIT_FAILURE;
    }
    /* ... read the readings ... */
    fclose(file);
    return EXIT_SUCCESS;
}
readings-2026-10.csv: No such file or directory

Built as a command-line tool, the same idea lets scripts react to the result. The convention many tools follow is 0 for success, 1 for a failure while doing the work and 2 for wrong usage:

cat > check_file.c <<'C'
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{
    if (argc != 2) {
        fprintf(stderr, "usage: %s FILE\n", argv[0]);
        return 2;                       /* convention: 2 = wrong usage */
    }
    FILE *file = fopen(argv[1], "r");
    if (file == NULL) {
        perror(argv[1]);
        return EXIT_FAILURE;            /* 1 */
    }
    fclose(file);
    printf("%s is readable\n", argv[1]);
    return EXIT_SUCCESS;                /* 0 */
}
C
gcc -std=c17 -Wall -Wextra -o check_file check_file.c
echo "M-30004,412" > readings.csv

./check_file readings.csv;  echo "exit status: $?"
./check_file missing.csv;   echo "exit status: $?"
./check_file;               echo "exit status: $?"
./check_file readings.csv && echo "next step runs"
readings.csv is readable
exit status: 0
missing.csv: No such file or directory
exit status: 1
usage: ./check_file FILE
exit status: 2
readings.csv is readable
next step runs

return n; in main and exit(n) anywhere else both flush and close open FILE streams and run functions registered with atexit. abort() does neither: it raises the SIGABRT signal, and the shell reports exit status 134 (128 + 6). A segmentation fault gives 139 (128 + 11). Reading those numbers is part of Debugging C.

Cleaning up: the goto cleanup pattern

A function that opens a file, allocates a buffer and then parses data has three places where it can fail, and each failure must release exactly what was acquired so far. Without exceptions or destructors, the usual answer in C is goto cleanup: on an error, jump forward to a chain of labels at the end of the function that release the resources in reverse order. The success path falls through the same labels, so there is one exit and every resource is released in one place.

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

/* Adds up the kWh column of a readings file ("meter,kwh" per line).
   Returns 0 on success, -1 on any error; every exit path frees and
   closes exactly what was acquired. */
static int sum_readings(const char *path, long *total)
{
    int result = -1;                    /* pessimistic default */
    long sum = 0;
    int line_no = 0;
    char *line = NULL;
    FILE *file = fopen(path, "r");
    if (file == NULL) {
        perror(path);
        goto out;                       /* nothing acquired yet */
    }
    line = malloc(64);
    if (line == NULL) {
        fprintf(stderr, "out of memory\n");
        goto close_file;
    }

    while (fgets(line, 64, file) != NULL) {
        line_no++;
        long kwh;
        if (sscanf(line, "%*[^,],%ld", &kwh) != 1) {
            fprintf(stderr, "%s:%d: bad line\n", path, line_no);
            goto free_line;
        }
        sum += kwh;
    }
    if (ferror(file)) {
        perror(path);
        goto free_line;
    }
    *total = sum;
    result = 0;                         /* success */

free_line:
    free(line);
close_file:
    fclose(file);
out:
    return result;
}

static void write_file(const char *path, const char *text)
{
    FILE *file = fopen(path, "w");
    if (file != NULL) {
        fputs(text, file);
        fclose(file);
    }
}

int main(void)
{
    write_file("good.csv", "M-30004,412\nM-30007,1500\n");
    write_file("bad.csv", "M-30004,412\nM-30007,n/a\n");

    const char *paths[] = {"good.csv", "bad.csv", "missing.csv"};
    for (int i = 0; i < 3; i++) {
        long total;
        if (sum_readings(paths[i], &total) == 0) {
            printf("%s: total %ld kWh\n", paths[i], total);
        } else {
            printf("%s: skipped\n", paths[i]);
        }
    }
    return 0;
}
good.csv: total 1912 kWh
bad.csv:2: bad line
bad.csv: skipped
missing.csv: No such file or directory
missing.csv: skipped
The goto cleanup pattern. On the left, the steps of sum_readings from top to bottom: fopen, malloc, read and add up lines, then store the result. On the right, the cleanup labels from bottom to top: free_line, close_file, out. A failure in fopen jumps to out, a failure in malloc to close_file, a failure while reading to free_line. Success flows into free_line, and each label falls through to the one above it, so resources are released in reverse order. file = fopen(path) line = malloc(64) read and add up lines result = 0 out: return result close_file: fclose free_line: free fails fails fails succeeds
Each failure jumps to the label that releases only what exists so far; the labels fall through in reverse order of acquisition.

A few details make the pattern work. result starts as the failure value, so only the line that is reached after everything succeeded changes it. Variables used after the labels are declared at the top, before the first goto. And the jumps only go forward, to the end of the same function — this is the one use of goto that most C style guides accept (see Control Flow and Loops). A variant with a single label works too, because free(NULL) does nothing: initialize every pointer to NULL and test before fclose.

assert: checks for programming mistakes

assert(expression), from <assert.h>, checks a condition that must always be true if the program is correct. When it is false, the program prints the file, line, function and expression to standard error and calls abort(). It documents an assumption and stops the program at the first sign that the assumption broke, close to the cause instead of far downstream.

#include <assert.h>
#include <stdio.h>

/* Cost in cents at the fixed tariff of 27 cents per kWh. */
static long cost_cents(int kwh)
{
    assert(kwh >= 0);                   /* callers validate input first */
    return kwh * 27L;
}

int main(void)
{
    printf("350 kWh: %ld cents\n", cost_cents(350));
    printf("-20 kWh: %ld cents\n", cost_cents(-20));
    return 0;
}
350 kWh: 9450 cents
example: example.c:7: cost_cents: Assertion `kwh >= 0' failed.
Aborted

Compiled with -DNDEBUG, which many release builds set, every assert expands to nothing: the check and any side effects inside it disappear. Two rules follow:

  • Use assert for bugs (a function called with an argument its caller should have checked), never for input validation or for errors that can happen in a correct program, such as a missing file. Those need real error handling.
  • Never put work inside it: assert(fclose(file) == 0) stops closing the file in a release build.

Checks at compile time

Some assumptions can be checked before the program ever runs. _Static_assert (C11, also available as the macro static_assert from <assert.h>; a keyword in C23) evaluates a constant expression at compile time and stops the build with your message when it is false. It suits facts about the platform and data layout, such as the size of a structure that is sent over a wire:

#include <stdint.h>
#include <stdio.h>

struct reading {
    uint32_t meter;
    uint16_t watts;
    uint16_t flags;
};

/* Checked by the compiler, costs nothing at run time. */
_Static_assert(sizeof(struct reading) == 8, "reading must stay 8 bytes");
_Static_assert(sizeof(long) == 4, "this code assumes a 32-bit long");

int main(void)
{
    printf("%zu\n", sizeof(struct reading));
    return 0;
}
example.c:12:1: error: static assertion failed: "this code assumes a 32-bit long"

The first assertion holds; the second fails on this machine, where long is 64 bits, so the assumption is caught at build time instead of as a wrong result in production.

setjmp and longjmp: non-local jumps

<setjmp.h> offers the closest thing C has to throwing an exception. setjmp(env) saves the current execution state and returns 0. A later longjmp(env, 1), from any function called in between, jumps back to that point, and setjmp appears to return a second time, now with 1.

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

static jmp_buf on_error;

static int parse_digit(char c)
{
    if (c < '0' || c > '9') {
        longjmp(on_error, 1);           /* back to setjmp, which now returns 1 */
    }
    return c - '0';
}

static int parse_number(const char *text)
{
    int value = 0;
    for (const char *p = text; *p != '\0'; p++) {
        value = value * 10 + parse_digit(*p);
    }
    return value;
}

int main(void)
{
    const char *inputs[] = {"412", "4x2"};
    for (int i = 0; i < 2; i++) {
        if (setjmp(on_error) == 0) {
            printf("%s -> %d\n", inputs[i], parse_number(inputs[i]));
        } else {
            printf("%s -> not a number\n", inputs[i]);
        }
    }
    return 0;
}
412 -> 412
4x2 -> not a number

It works, but nothing between the longjmp and the setjmp gets a chance to clean up: memory allocated and files opened in the skipped functions leak. The function that called setjmp must still be running when longjmp is called, and its local variables that changed after setjmp have indeterminate values afterward unless they are declared volatile. Interpreters and some parsers use it carefully; in ordinary code, return values are clearer.

Good practices

  • Check every return value that can signal failure, especially fopen, malloc, scanf, fclose and strtol. Where you deliberately ignore one, write (void) before the call so readers know it was a choice (this silences [[nodiscard]], but GCC still warns for __attribute__((warn_unused_result))).
  • Pick one convention per code base — for example "0 or a negative error code" — and document it next to each function.
  • Report errors where you can add context (which file, which line of input), and only once: either log and handle, or return the code to the caller, not both at every level.
  • Write diagnostics to standard error and finish with a non-zero exit status, so scripts and schedulers notice the failure.
  • Release resources on every path; goto cleanup keeps that in one place.
  • Use assert and _Static_assert for assumptions, not for input — and treat input from files, networks and devices as hostile until it is validated.

The memory errors that no return value reports — reading past a buffer, using freed memory — are the subject of Undefined Behavior and Safer C.