Linux System Programming · intermediate · ~8 min

errno and error reporting

By the end of this lesson you will be able to: - Recognize how POSIX functions signal failure (a sentinel return value such as `-1` or `NULL`) versus how they explain failure (the `errno` variable). - Read `errno` correctly: check the return value *first*, then read `errno` *immediately*, before any other library call can overwrite it. - Convert a numeric error code into human-readable text with `perror` and `strerror` (and the thread-safe `strerror_r`). - Compare specific error codes (`ENOENT`, `EACCES`, `EINTR`, ...) so your program can react differently to different failures. - Follow the standard C convention in your *own* functions: on error, set `errno` and return a sentinel; on success, leave `errno` alone. - Save `errno` into a local variable when you need its value later, and explain why this matters in real programs.

Overview

When you call a function that talks to the operating system — opening a file, reading from a socket, allocating memory — that call can fail for reasons completely outside your control. The disk could be full, the file might not exist, you might lack permission, or another process could have deleted the file a microsecond ago. A robust program does not just hope these calls succeed; it checks every one and reacts sensibly when they do not.

This lesson builds directly on open / read / write / close, where you saw that open returns -1 on failure and read/write return -1 too. That -1 tells you something went wrong, but not what. The missing piece is errno: a small integer the C library and the kernel set to a code describing the reason for the most recent failure. Together, the return value and errno form the standard error-reporting contract of POSIX and the C standard library.

In plain language: the return value answers "did it fail?" and errno answers "why did it fail?". You always read them in that order. Checking errno without first seeing a failing return value is meaningless, because errno is only guaranteed to be set when a function actually reports an error.

The terminology you will meet here:

  • errno — an int-valued expression (declared in <errno.h>) that holds the last error code. On modern systems it is thread-local: each thread has its own copy.
  • Error constants — symbolic names like ENOENT ("No such file or directory") and EACCES ("Permission denied"). Always compare against these names, never against raw numbers, because the numbers differ between systems.
  • Sentinel value — the special return value (-1, NULL, sometimes (size_t)-1) a function uses to mean "I failed; look at errno."
  • perror / strerror — helpers that translate an errno code into a readable message.

Why it matters

Almost every real-world bug report that starts with "it just doesn't work" comes down to an unchecked error. A program that ignores a failed open will happily read from file descriptor -1, get more failures, and eventually crash or silently corrupt data — with no clue about the original cause. Reading and reporting errno turns a baffling crash into a one-line, actionable message: open config.txt: Permission denied.

Good error reporting is also a reliability and security property. A server that distinguishes ENOENT (file missing) from EACCES (permission denied) from EMFILE (too many open files) can respond correctly: create the file, refuse the request, or shed load. A program that treats all failures identically — or, worse, treats failure as success — is exactly the kind of software that loses data, leaks resources, or hangs under pressure.

In professional codebases, consistent errno handling is the difference between logs you can debug at 3 a.m. and logs that tell you nothing. It is one of the most quietly important habits in systems programming.

Core concepts

1. The two-step failure protocol

Definition. Most POSIX and standard-library functions report failure by returning a sentinel value and setting errno to an error code.

How it works. When a system call fails inside the kernel, the C library wrapper stores the kernel's error number into your thread's errno and returns the sentinel (-1, or NULL for pointer-returning functions). Your code must check the return value first; only if it indicates failure is errno meaningful.

  your code            libc wrapper            kernel
  --------             ------------            ------
  fd = open(...)  -->  syscall ............>  tries to open
                                              fails: EACCES
  fd == -1       <--   set errno=EACCES <---  returns error
  read errno  ----------------------------->  EACCES ("Permission denied")

When to use it. Every time you call a function whose documentation says "on error, returns X and sets errno" — which is the vast majority of <stdio.h>, <unistd.h>, <fcntl.h>, and <stdlib.h> I/O and resource functions.

When NOT to rely on it. Some functions (e.g. string functions like strcpy) do not set errno at all. And a successful call may leave errno holding garbage from a previous failure — success does not clear it. So never infer success from errno.

Pitfall. Writing if (errno != 0) to detect failure. errno is not reset to 0 on success, so this gives false positives. Always test the return value.

Knowledge check (concept): A function returns NULL. Is errno guaranteed to describe the reason? (Answer: only if that function's documentation states it sets errno on failure — fopen does, but a generic helper might not.)

2. errno is fragile — read it immediately, save it if needed

Definition. errno reflects the most recent error. Any subsequent library call may overwrite it, even a call that succeeds.

How it works. errno is a single per-thread slot. When you call fprintf to print an error message, fprintf itself may set errno (for example to EINTR or 0). If you read errno after that, you may see the wrong code.

TIME ->
  open(...) fails .......... errno = ENOENT   <-- the value you want
  fprintf(stderr, "...") .... errno = (maybe changed!)
  strerror(errno) .......... reads the WRONG value

The fix is to capture errno the instant the failing call returns:

int fd = open(path, O_RDONLY);
if (fd < 0) {
    int saved = errno;            /* snapshot before anything else */
    do_some_logging();
    fprintf(stderr, "%s\n", strerror(saved));
}

When to use the snapshot. Whenever you do anything between the failing call and your use of errno, or when you must restore errno afterward (common in library code that should not disturb the caller's errno).

Pitfall. Calling perror or strerror is usually fine immediately after the failing call, but inserting even one innocent-looking statement (a printf, a close, a malloc) between them can corrupt the value.

Knowledge check (find-the-bug):

if (read(fd, buf, n) < 0) {
    close(fd);                      /* line A */
    perror("read failed");          /* line B */
}

Why might line B print the wrong reason? (Answer: close on line A can itself fail and set errno, so by line B errno no longer reflects the read error. Save errno before close.)

3. Turning errno into a message: perror and strerror

Definition. perror and strerror map a numeric code to descriptive text.

  • void perror(const char *s) prints s, then ": ", then the message for the current errno, then a newline — all to stderr.
  • char *strerror(int errnum) returns a pointer to a string for the given code, which you can format yourself.
  • int strerror_r(int errnum, char *buf, size_t buflen) is the thread-safe variant that writes into your buffer (recommended in multithreaded code).

How it works internally. Both look up the code in a system message table. strerror may return a pointer to a static buffer that is shared and may be overwritten by the next strerror call — which is why strerror_r exists for threads.

When to use which.

Need Use
Quick message + your own prefix, single-threaded perror("context")
Embed the message inside a larger formatted string strerror(errno)
Multithreaded code, or you must not risk a shared buffer strerror_r(...)

Pitfall. Saving the pointer from strerror and reusing it later. The string it points to may change. Copy it if you need to keep it.

Knowledge check (predict-the-output): If errno == ENOENT and you call perror("open config.txt"), what is printed to stderr? (Answer: open config.txt: No such file or directory.)

4. Specific error codes let you react, not just report

Definition. Error constants such as ENOENT, EACCES, EINTR, EAGAIN, EEXIST, and EINTR name specific failure causes.

How it works. After a failed call you can branch on the code. The classic example is EINTR: a slow system call interrupted by a signal. The correct response is usually to retry, not to abort.

ssize_t n;
do {
    n = read(fd, buf, len);
} while (n < 0 && errno == EINTR);   /* retry on interruption */
if (n < 0) {
    perror("read");
}

When to use it. Whenever different failures deserve different handling: create-if-missing (ENOENT), report-and-stop (EACCES), retry (EINTR/EAGAIN), back off (EMFILE).

Pitfall. Comparing errno to a literal number (e.g. errno == 2). The numeric value of ENOENT is not portable; always use the symbolic name from <errno.h>.

5. Setting errno in your own functions

Definition. When you write a function that can fail, follow the same contract: on error, set errno to a meaningful code and return a sentinel; on success, return a non-error value and do not touch errno.

How it works. You may assign one of the standard constants, e.g. errno = EINVAL; for a bad argument or errno = EDOM; for a math-domain error (like dividing by zero in a checked divide). Callers can then use perror/strerror on your function exactly as they would on a libc call.

  caller                your_function
  ------                -------------
  rc = checked_div(...) --> if (b==0) { errno=EDOM; return -1; }
  rc == -1 ?            <--                 else *out = a/b; return 0;
  perror("checked_div")

When to use it. Library-style functions and any helper that wraps failure-prone operations.

Pitfall. Setting errno on the success path. That breaks callers who, correctly, only read errno after seeing your sentinel — but it also pollutes the value for code that (incorrectly but commonly) inspects errno loosely. Leave errno untouched when you succeed.

Syntax notes

#include <errno.h>     /* declares errno and the E* constants */
#include <string.h>    /* strerror, strerror_r */
#include <stdio.h>     /* perror, fprintf */

/* errno is an lvalue you can read AND assign: */
errno = 0;                         /* optional reset before a call that may not set it */
FILE *f = fopen("x", "r");
if (f == NULL) {                   /* 1. check sentinel first */
    int e = errno;                 /* 2. snapshot immediately */
    fprintf(stderr, "fopen: %s\n", strerror(e));   /* 3. translate */
}

/* perror automatically uses the current errno and prints to stderr: */
if (remove("x") != 0)
    perror("remove");             /* prints: remove: <reason> */

Key points:

  • errno may be a macro that expands to a function call (*__errno_location()), so treat it as a normal int lvalue but always include <errno.h>.
  • perror's argument is a context prefix, usually the operation or filename. Pass NULL or "" to print just the message.
  • For threads, prefer strerror_r(errnum, buf, sizeof buf) over strerror.

Lesson

How POSIX reports errors

Most POSIX functions signal failure in two steps:

  • They return a special value — usually -1, or NULL for functions that return a pointer.
  • They set the global variable errno to a numeric code that describes what went wrong.

The return value tells you that a call failed. errno tells you why.

Turning errno into a message

Two helpers convert errno into readable text:

  • perror("context") prints context: <human-readable error> to standard error (stderr).
  • strerror(errno) returns the matching message as a string, which you can print yourself.

errno is fragile — save it early

On modern systems errno is thread-local (each thread has its own copy), so one thread cannot clobber another thread's value.

But within a single thread, the very next library call can overwrite errno. If you need the value later, copy it into your own variable immediately after the failing call.

Code examples

#include <stdio.h>      /* fprintf, perror */
#include <string.h>     /* strerror */
#include <errno.h>      /* errno, error constants */
#include <fcntl.h>      /* open, O_RDONLY */
#include <unistd.h>     /* read, close */

#define BUF_SIZE 256

/* Open a file, read up to BUF_SIZE bytes, and print a clear diagnostic
   on any failure. Returns 0 on success, 1 on failure. */
int dump_head(const char *path) {
    int fd = open(path, O_RDONLY);
    if (fd < 0) {
        int saved = errno;                 /* snapshot before any other call */
        /* React differently based on the specific cause: */
        if (saved == ENOENT) {
            fprintf(stderr, "open %s: file does not exist\n", path);
        } else if (saved == EACCES) {
            fprintf(stderr, "open %s: permission denied\n", path);
        } else {
            fprintf(stderr, "open %s: %s\n", path, strerror(saved));
        }
        return 1;
    }

    char buf[BUF_SIZE];
    ssize_t n;
    do {
        n = read(fd, buf, sizeof buf);
    } while (n < 0 && errno == EINTR);     /* retry if interrupted by a signal */

    if (n < 0) {
        int saved = errno;                 /* save before close() can change it */
        close(fd);                         /* clean up the descriptor regardless */
        fprintf(stderr, "read %s: %s\n", path, strerror(saved));
        return 1;
    }

    if (fwrite(buf, 1, (size_t)n, stdout) != (size_t)n) {
        perror("fwrite");                  /* perror reads current errno */
        close(fd);
        return 1;
    }

    if (close(fd) < 0) {                    /* even close can fail */
        perror("close");
        return 1;
    }
    return 0;
}

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "usage: %s <path>\n", argv[0]);
        return 2;
    }
    return dump_head(argv[1]);
}

What it does. The program takes a filename on the command line, opens it read-only, reads up to 256 bytes, and writes them to standard output. Every system call is checked. On failure it prints a specific, human-readable diagnostic to stderr and returns a non-zero exit status.

Expected output.

  • Run on an existing readable file notes.txt containing hello:
    hello
    
    (printed to stdout; exit status 0)
  • Run on a missing file: ./a.out nope.txt prints to stderr:
    open nope.txt: file does not exist
    
    and exits with status 1.
  • Run on a file you cannot read prints open <path>: permission denied.

Edge cases. A read of 0 bytes means end-of-file, not an error (here it simply writes nothing). The EINTR retry loop matters on systems where signals can interrupt blocking calls. close is checked because on some filesystems a deferred write error surfaces only at close.

Line by line

  1. int fd = open(path, O_RDONLY); — request a read-only file descriptor. On failure open returns -1 and sets errno.
  2. if (fd < 0) — the sentinel check. We act only because the return value signaled failure.
  3. int saved = errno; — immediately snapshot the code. From here on, any function we call could overwrite the global errno, so we work from saved.
  4. The if (saved == ENOENT) ... else if (saved == EACCES) ... else strerror(saved) block — branches on the specific cause to give a tailored message, falling back to the system message for anything else.
  5. do { n = read(...); } while (n < 0 && errno == EINTR);read returns the number of bytes read, 0 at EOF, or -1 on error. If the error is EINTR (interrupted by a signal) we retry; any other error breaks the loop.
  6. After the loop, if (n < 0) handles a genuine read error. We snapshot errno before calling close, because close can fail and overwrite it.
  7. fwrite(buf, 1, n, stdout) writes exactly n bytes; if the count returned does not match, perror("fwrite") reports why using the current errno (safe here because nothing intervened).
  8. close(fd) releases the descriptor; we check it too, since a buffered write error can appear at close time.

A trace for ./a.out nope.txt when the file is missing:

Step Call Return errno Action
1 open("nope.txt", O_RDONLY) -1 ENOENT enter failure branch
2 snapshot saved=ENOENT saved == ENOENT true
3 fprintf(stderr, ...) (may change) prints "file does not exist"
4 return 1 main returns 1

Common mistakes

Mistake 1 — reading errno without checking the return value.

fopen("x", "r");
if (errno != 0) puts("failed");   /* WRONG */

Why it's wrong: errno is not cleared on success, so it may hold a stale code from an earlier failure, producing a false "failed". Recognize it when your program reports errors that never happened.

FILE *f = fopen("x", "r");
if (f == NULL) perror("fopen");   /* RIGHT: check the sentinel */

Mistake 2 — letting another call clobber errno first.

if (open(path, O_RDONLY) < 0) {
    printf("trying again...\n");   /* printf may change errno */
    perror("open");                /* WRONG: now reports printf's errno */
}

Fix: snapshot before doing anything.

if (open(path, O_RDONLY) < 0) {
    int e = errno;
    printf("trying again...\n");
    fprintf(stderr, "open: %s\n", strerror(e));
}

Recognize it when error messages are oddly generic ("Success", "Invalid argument") for an operation that clearly failed for another reason.

Mistake 3 — comparing errno to a raw number.

if (errno == 2) ...   /* WRONG: 2 happens to be ENOENT on Linux, not portable */
if (errno == ENOENT) ...  /* RIGHT */

Prevent it by always using the E* constants from <errno.h>.

Mistake 4 — treating EOF or a short read as an error.

if (read(fd, buf, n) != n) handle_error();   /* WRONG */

read legitimately returns fewer bytes than requested, and 0 means EOF. Only -1 is an error. Check < 0 for errors and loop for partial reads.

Mistake 5 — setting errno on the success path of your own function. Leave errno untouched when you succeed; only set it when you return your sentinel.

Debugging tips

Compiler errors.

  • 'errno' undeclared — you forgot #include <errno.h>.
  • implicit declaration of function 'strerror' — add #include <string.h>; for perror/fprintf add #include <stdio.h>.
  • Warnings about comparing ssize_t and size_t when checking read results — cast deliberately, e.g. (size_t)n, only after you have confirmed n >= 0.

Runtime symptoms.

  • Message says Success even though the call failed: you read errno too late or never checked the return value. Snapshot errno immediately and verify you tested the sentinel.
  • The wrong filename appears in the message: pass the right context string to perror/fprintf.
  • Program hangs or loops forever on read: an EINTR retry loop with no other exit condition combined with a never-returning call — confirm the loop also exits on real errors and EOF.

Logic errors. If error handling never triggers, print the return value too: fprintf(stderr, "rc=%d errno=%d (%s)\n", rc, errno, strerror(errno)); during debugging.

Questions to ask when it doesn't work.

  1. Did I check the return value before touching errno?
  2. Did anything run between the failing call and my use of errno?
  3. Am I comparing against a symbolic E* name, not a number?
  4. Could the cause be EINTR (needs retry) rather than a fatal error?
  5. Is this function even documented to set errno?

Memory safety

This is not a security lesson, but errno handling intersects with several robustness and undefined-behaviour concerns:

  • Using a failed handle. If open returns -1 and you ignore it, later read(-1, buf, n) simply fails again — but if you instead use an uninitialized fd (because you skipped the check), you may read/write a random descriptor. Always check before use.
  • Buffer bounds with read. read returns the byte count; treat the buffer as containing exactly that many valid bytes. Never assume the buffer is full or null-terminated. Writing past n bytes is a buffer over-read/over-write.
  • Signedness of return values. read/write return ssize_t (can be negative); storing the result in an unsigned type hides the -1 error as a huge positive number, defeating your check. Use ssize_t.
  • strerror is not thread-safe. In multithreaded code, two threads calling strerror can corrupt each other's message. Use strerror_r with a caller-owned buffer to avoid a data race.
  • Don't log secrets. Even in non-security code, error messages are written to logs; never include passwords, tokens, or full request bodies in the context string you pass to perror/fprintf.
  • errno lifetime. The snapshot you save is a plain int local — safe. The pointer returned by strerror may point into a shared/static buffer with limited lifetime; copy the text if you must keep it.

Real-world uses

Concrete uses.

  • System utilities. Tools like cat, cp, and ls print exactly the errno-derived messages you see (cat: missing.txt: No such file or directory). That message is literally perror/strerror output with the program name as prefix.
  • Servers and daemons. Web servers branch on errno to distinguish a missing file (404), a permission problem (403), and resource exhaustion (EMFILE → 503 or backpressure). Network code retries on EINTR/EAGAIN.
  • Databases and storage engines. They check close/fsync errors via errno to know whether data actually reached disk before acknowledging a commit.
  • Embedded and OS code. Device drivers and libc itself propagate kernel error numbers through errno so user programs get consistent diagnostics.

Professional best-practice habits.

Beginner rules:

  • Check every system call's return value; never assume success.
  • Snapshot errno immediately into a local when you'll use it later.
  • Use perror/strerror instead of inventing your own message text.
  • Always include <errno.h> and compare against E* constants.

Advanced rules:

  • Use strerror_r in multithreaded programs.
  • Distinguish recoverable errors (EINTR, EAGAIN) from fatal ones and handle each appropriately.
  • In library code, preserve the caller's errno: save it on entry and restore it on the success path if your function makes internal calls.
  • Centralize logging so context, the operation, and strerror output are recorded consistently, without leaking sensitive data.

Practice tasks

Beginner 1 — Report a missing file. Write a program that takes a path as argv[1], calls open(path, O_RDONLY), and if it fails prints open <path>: <reason> to stderr using strerror(errno), then exits non-zero. Requirements: check the return value before reading errno; include the path in the message. Example: ./a.out nopeopen nope: No such file or directory. Concepts: sentinel check, strerror.

Beginner 2 — perror practice. Modify the program above to use perror("open") instead of building the string yourself. Compare the two outputs. Hint: perror automatically appends ": " and the current message and writes to stderr. Concepts: perror, errno is read at call time.

Intermediate 1 — Branch on the cause. Extend the program to print a custom message for ENOENT ("file not found"), a different one for EACCES ("permission denied"), and fall back to strerror for any other code. Requirements: snapshot errno before the branching; use the symbolic constants. Constraint: no raw numeric comparisons. Concepts: specific error codes, errno snapshot.

Intermediate 2 — Save errno across a cleanup call. Write a function that opens a file, then on a simulated failure path calls close(fd) and afterward reports the original error correctly. Demonstrate that without saving errno the message is wrong, and with saving it is right. Hint: deliberately make close run between the failure and the report. Concepts: errno fragility, snapshotting.

Challenge — Your own errno-setting function. Implement int safe_read_all(int fd, void *buf, size_t len, size_t *out_n) that reads exactly len bytes (looping over partial reads, retrying on EINTR). On success return 0 and set *out_n. On EOF before len bytes, set errno = EIO (or your chosen code) and return -1. On a real read error, leave errno as read set it and return -1. Requirements: never set errno on the success path; handle partial reads; document which codes a caller may see. Constraints: no global state, no fixed-size assumptions. Concepts: the full failure protocol, EINTR retry, setting errno in your own API.

Summary

  • POSIX/standard-library functions report failure with a sentinel return value (-1 or NULL) and explain it with errno. The return value answers "did it fail?"; errno answers "why?".
  • Read them in order: check the return value first, then read errno immediately. Never infer failure from errno alone — it is not cleared on success.
  • errno is thread-local but fragile: the next library call (even a successful one) can overwrite it. Snapshot it into a local variable if you'll use it after any other call.
  • Translate codes with perror("context") or strerror(errno) (use strerror_r in threads). Compare against symbolic E* constants, never raw numbers.
  • Branch on specific codes (ENOENT, EACCES, EINTR, ...) to react correctly — retry on EINTR, report on EACCES, create on ENOENT.
  • In your own functions, follow the convention: set errno and return a sentinel on error; leave errno untouched on success.
  • The most common mistakes: testing errno without checking the return value, letting another call clobber errno before you read it, comparing errno to a number, and treating EOF/short reads as errors.

Practice with these exercises