C++ Exceptions vs Error Codes
Compare exceptions and error codes in C++ for handling failures — performance, ergonomics, and when each style fits real codebases.
What you'll learn
- ✓Compare exception-based and error-code-based error handling
- ✓Understand the runtime cost of throwing
- ✓Use std::expected and std::optional for typed errors
- ✓Pick a style that fits your codebase and team
- ✓Avoid mixing styles inconsistently
Prerequisites
- •Functions and references — see /blog/cpp-functions-and-references
Every non-trivial program must report failures. C++ gives you two main mechanisms: exceptions that unwind the stack, and return-value-based error codes. Each has trade-offs, and large codebases often pick one and stick with it. This article walks through both.
What and Why
Exceptions let a deep function signal failure without polluting every signature on the way up. A throw aborts normal flow until a matching catch is found, running destructors as the stack unwinds. Error codes, by contrast, return success or failure as a normal value. Callers branch on the result and propagate manually.
The reason to care is that this choice ripples through your whole API. Mixing styles inconsistently — some functions throw, others return codes — produces code that is hard to read and easy to break.
Mental Model
Think of exceptions as an out-of-band channel. Normal returns travel one path, exceptions travel another. Error codes squash both into a single path: every call site must inspect the result. Exceptions favor the happy path at the cost of hidden control flow. Error codes favor explicitness at the cost of verbosity.
Modern C++ adds a third option: returning a sum type like std::expected<T, E> or std::optional<T>. These keep the result in the normal return channel but make the failure case impossible to ignore at compile time.
Hands-on Example
Here is the same operation written three ways.
#include <expected>
#include <stdexcept>
#include <string>
// Exception style
int parseExc(const std::string& s) {
if (s.empty()) throw std::invalid_argument("empty");
return std::stoi(s);
}
// Error code style
bool parseCode(const std::string& s, int& out) {
if (s.empty()) return false;
out = std::stoi(s);
return true;
}
// std::expected style (C++23)
std::expected<int, std::string> parseExp(const std::string& s) {
if (s.empty()) return std::unexpected("empty");
return std::stoi(s);
}
Exception: call ──> [ok value] ──┐
└─> throw ──> catch handler
Error code: call ──> (bool, out) ──> if (!ok) handle
Expected: call ──> expected<T,E> ──> .has_value() ? use : handle
All three model success vs failure differently. The exception version reads cleanly when nested deeply. The error code version forces every caller to check. The std::expected version gives type-safe failures without the runtime cost of throwing.
Common Pitfalls
A frequent mistake is throwing from destructors. Destructors run during stack unwinding from another exception, and a second exception will call std::terminate. Always mark destructors noexcept (the default) and catch internally if needed.
Another pitfall is ignoring exception safety. If a function throws halfway through, partially mutated state must remain valid. Aim for the strong guarantee where possible — operations either fully succeed or have no effect. RAII makes this much easier.
With error codes, a common bug is forgetting to check the return value. Some teams require [[nodiscard]] on every fallible function to make this a compiler warning.
Mixing styles silently is worst of all. If half your library throws and the other half returns codes, callers will guess wrong.
Practical Tips
For performance-critical inner loops where failures are rare, exceptions are typically faster than checking error codes on every call — they cost nothing when not thrown. For hot paths where failures are routine, prefer std::expected or std::optional.
In embedded or freestanding code, exceptions are often disabled. There, error codes or std::expected are the only option.
Pick one style at the library boundary and document it. Inside a function, RAII for cleanup makes both styles safer.
Use exception types that derive from std::exception so generic handlers can log a message. Add custom error categories with std::error_code for richer failure information without throwing.
Wrap-up
Exceptions and error codes both work — what fails is mixing them haphazardly. Choose based on your domain: exceptions for libraries with rare failures and deep call stacks, error codes or std::expected when failure is part of normal flow. Then enforce the choice with [[nodiscard]], RAII, and consistent API design.
Related articles
- C++ C++ Design Patterns Overview
Tour the most useful design patterns in modern C++ — singleton, factory, observer, strategy, RAII — and learn when each one earns its keep.
- C++ C++ std::string_view Explained
Learn how std::string_view gives you cheap, non-owning views into strings — when to use it, how it speeds up code, and how to avoid dangling references.
- C++ C++ Unit Testing with GoogleTest
Set up GoogleTest in a CMake project and write clear unit tests with assertions, fixtures, and parameterized tests for confident C++ code.
- C++ C++ Classes, Constructors, and the Rule of Three
Design C++ classes with constructors, destructors, member initializer lists, and the Rule of Three and Five to manage resources safely.