There's a pattern I've noticed in codebases that have grown beyond one person's head: error handling starts
reasonable, then balloons into a second shadow program — layers of Result<T, E>, custom error enums,
propagation macros, error contexts, error contexts for the error contexts. The actual logic drowns in ceremony.
Meanwhile the bugs that kill products in production aren't subtle network timeouts — they're invariant
violations that should never have been possible at all.
My take, after shipping a few real systems: most of what you want is just a well-placed assert.
What asserts actually do
An assert is a contract. It says: "If this is false, my program's logic is broken. Don't try to recover. Crash immediately and loudly." That's it. No allocation, no vtable dispatch, no log pipeline. Just a conditional and an abort.
#include <assert.h>
void push(Stack *s, int val) {
assert(s != NULL);
assert(s->len < s->cap);
s->data[s->len++] = val;
}
Read this and you immediately know: the caller is responsible for not passing null and for not overfilling
the stack. No return value to check. No error propagation. If either condition is violated, you get
a crash with a file and line number pointing exactly at the bug. That's more useful than a silent
NULL return that manifests as a crash three call frames later.
The two categories of failure
Before anything else, you need to internalize a distinction that most codebases blur:
- Programming errors — invariant violations, broken preconditions, logic bugs. These are your fault. They should never happen in correct code. Assert these.
- Environmental failures — file not found, network timeout, OOM. These depend on the world outside your binary. Handle these with real error paths.
The mistake most people make is treating programming errors as recoverable. They return false
from a function when an index is out of bounds, and then some caller ignores it, and then you spend
three hours in a debugger. An out-of-bounds index is a bug. Crash. Fix it. Move on.
Asserts in release builds
The common objection: "But asserts get compiled out with NDEBUG!" True for the standard
assert(). The solution is trivial — write your own:
#define ASSERT(cond) \
do { \
if (!(cond)) { \
fprintf(stderr, "ASSERT failed: %s\n at %s:%d\n", \
#cond, __FILE__, __LINE__); \
abort(); \
} \
} while(0)
Now it stays in release. "But what about performance?" If your hot path is assert-heavy enough that this matters, you have bigger problems. Most asserts are on function entry — they run once per call and the branch predictor will never miss on them. The overhead is immeasurable.
For the genuinely hot paths, you can add a DEBUG_ASSERT variant that strips in release.
But be honest about when you actually need that. Usually you don't.
Asserts as documentation
This is the underrated benefit. When I see:
Matrix4 invert(Matrix4 m) {
float det = determinant(m);
assert(fabsf(det) > 1e-6f && "Matrix must be invertible");
// ...
}
I know exactly what this function requires without reading a docstring (that may be stale). The assert is executable documentation. It runs on every call in debug. It cannot go out of sync with the code the way comments do. It is, in a very real sense, the best kind of documentation: verifiable.
Where asserts fall short
I'm not saying throw away error handling. File I/O fails. Memory allocation fails (sometimes). User input is always garbage. For these, you do need real error paths — and they should be explicit and visible at the call site.
The point isn't to assert everything. The point is to stop reaching for complex error machinery when you actually mean "this is a bug, catch it hard." Save the heavy lifting for failures that are genuinely expected and recoverable.
A practical heuristic
Before adding an error return to a function, ask: can this condition ever be true in correct code?
If the answer is no — if it would only happen because of a bug in the caller — make it an assert.
Reserve bool/Result/error codes for things that can fail in a fully correct program
due to factors outside your control.
You'll find your codebases get smaller, faster to read, and easier to debug. The asserts will catch bugs at the exact moment they're introduced. And you'll stop wading through error propagation chains to diagnose what is, at the end of the day, a typo in an index calculation.
Most of the time, assert is more than enough. Trust it.