The problem: a syscall can come back having failed
From the earlier guides in this rung you already have the picture: when you call something like open(), control traps into the kernel, the kernel does privileged work on your behalf, and then you come back to user mode with a result. We drew the happy path: open() hands you back a small integer, a file descriptor, and you carry on. But the kernel does not always succeed. The file you named might not exist. The disk might be full. You might simply lack permission to touch it. The kernel cannot magically make the file appear — so it has to come back and tell you, in user mode, that it failed and why. That reporting convention is the whole subject of this guide.
Here is the awkward part, and it is worth naming honestly. A C function can only hand back one return value. The kernel, through its libc wrapper, needs to communicate two things at once: did it succeed or fail, and if it failed, which of the dozens of possible reasons applied. C has no built-in way to return a pair, so the designers of Unix split the message across two channels. The function's own return value answers the first question — success or failure — using a reserved sentinel value. A separate global-looking integer named errno answers the second — the specific reason. Two channels, one for the verdict, one for the explanation.
The contract: sentinel return first, errno only after
Every POSIX call follows the same two-step contract, and once you internalise it you can read any of them. Step one: look at the return value, and compare it to that function's documented sentinel — the reserved value that cannot be a real answer and therefore means "failed." The sentinel differs per function, which trips up beginners. open() returns -1 on failure (a valid descriptor is always 0 or larger). malloc() returns a NULL pointer on failure (a real allocation is never NULL). read() and write() return -1. fork() returns -1. The pattern is always: pick a value the success path can never produce, and reserve it to mean failure.
Step two — and this is the rule people break — you read errno only after the return value already told you it failed. errno holds the reason: a small integer with a name like ENOENT (no such file or directory), EACCES (permission denied), ENOMEM (out of memory), or EINTR (the call was interrupted by a signal). The crucial honesty here: a successful call does not clear errno to zero. errno is only set on failure and is left untouched on success. So if you read errno without first checking the return value, you may be reading a stale reason left over from some earlier failed call — a value that has nothing to do with the call you just made. The return value is the gate; errno is meaningful only once that gate says "failed."
fd = open(path, O_RDONLY); /* step 1: the verdict */
if (fd == -1) { /* sentinel says it failed */
/* step 2: NOW errno holds the reason */
/* errno == ENOENT -> no such file
errno == EACCES -> permission denied */
perror("open"); /* prints: open: No such file or directory */
return -1;
}
/* fd is a valid descriptor here; do NOT consult errno */Why errno is not really one global variable
errno looks like an ordinary global integer, and for a long time it genuinely was a single global `int errno;` shared by the whole program. That was fine when programs had one thread of control. But think about what happens the moment a program has two threads (you will meet these properly in a later rung): thread A calls open(), it fails and sets the one shared errno to ENOENT; before thread A reads it, thread B calls a different failing call and overwrites the same errno with EACCES. Thread A now reads the wrong reason. A single shared errno is a data race waiting to happen.
So modern systems quietly changed what errno is. On any threaded platform today, errno is no longer a plain variable but a macro that expands to a function call — something morally like `(*__errno_location())` — and that function returns the address of a per-thread errno, one private copy living in each thread's own storage. Each thread reads and writes its own errno, so thread A's ENOENT can never be clobbered by thread B. The header `<errno.h>` hides all of this: you still write the word `errno` and assign and test it as if it were a variable, but underneath it resolves to your thread's private slot. This is one concrete use of thread-local storage you can point to.
Turning a number into something readable
An integer like 2 is not a useful error message for a human. So libc gives you two helpers that turn an errno value into English (and, with locale support, other languages). The first is perror("open"): it looks at the current errno, prints the string you passed, then a colon, then the human-readable reason, then a newline — for example `open: No such file or directory`. The second is strerror(errno), which does not print anything but returns the message string, so you can fold it into your own formatted output, such as `fprintf(stderr, "could not open %s: %s\n", path, strerror(errno));`. Both translate the same table of numbers to text.
Notice where these messages go. perror() and the example above write to standard error, the stream we call stderr, not to standard output. That separation is deliberate and you will meet the standard streams properly in the next guide. The short version: stdout is for a program's real results — the thing a pipeline downstream wants to consume — while stderr is for diagnostics like error messages, kept separate precisely so that piping a program's output does not swallow its complaints. Reporting an error to stderr is the convention; printing it to stdout would pollute the data.
Why you must check, every time
It is tempting to skip the check — "open() basically always works." Resist that, hard. The entire errno mechanism is useless if you don't read the sentinel, and the cost of skipping it is not a clean crash, it is silent corruption later. Suppose open() failed and returned -1, but you ignored the verdict and passed that -1 straight into read(fd, ...). Now you are reading from descriptor -1, which is not your file at all; read() fails too, you ignore that, and your buffer keeps whatever garbage it had. Your program marches on computing confidently on nonsense. The original error is long gone; the symptom shows up pages later, somewhere unrelated. This is the real meaning of the cost of unchecked errors: not the failure you skipped, but the corrupted state it leaves behind.
So the discipline is simply: check every call that can fail, at the point it can fail. For each failing call you then decide one of three things, and being deliberate about which is most of error handling. You can handle it locally — retry, fall back, use a default. You can propagate it — return your own error to your caller so they decide, which is how an error climbs back up through layers. Or you can abort — if the error means the program cannot sensibly continue, report it to stderr and exit with a non-zero exit status so whoever ran you knows it failed. The honest framing is handle, propagate, or abort: one of those three for every error, never "ignore."
It is fair to admit this convention shows its age. Splitting the verdict from the reason across a return value and a side-channel errno is error-prone exactly because the two can drift apart — forget to check the return, or let an intervening call stomp errno, and the whole scheme misleads you. Newer languages reacted to this. Rust, for instance, makes a call return a single Result value that is either the success or the error, so the compiler can force you to look at it before you use what is inside — the failure cannot be silently dropped the way an unchecked -1 can. That is not magic and Rust has its own costs, but it is a direct, honest answer to the exact weakness of the errno style: it makes "I forgot to check" into a compile error instead of a 3am production crash.