Error handling
C++ provides exceptions as the principal error-propagation mechanism. The combination of exceptions and RAII is the C++ idiom for error handling: when an exception propagates, every object in scope is destroyed in reverse construction order, releasing resources automatically. The mechanism is integrated with constructors (which signal failure by throwing), with the standard library (which throws on most error conditions), and with the type system (the noexcept specifier). The alternatives — error codes, std::optional, std::expected, assert — coexist with exceptions and are appropriate in different contexts; choosing among them is one of the design decisions in any non-trivial C++ codebase.
This page covers the exception model, the discipline of exception safety (basic, strong, no-throw), the noexcept specifier, the value-level alternatives, and the conventions that distinguish exception-using code from exception-free code.
The exception model
An exception is raised by throw, propagates up the call stack, and is caught by the nearest matching catch clause:
double divide(int numerator, int denominator) {
if (denominator == 0) {
throw std::runtime_error("division by zero");
}
return static_cast<double>(numerator) / denominator;
}
try {
double r = divide(a, b);
use(r);
} catch (const std::runtime_error &e) {
std::cerr << "error: " << e.what() << '\n';
}
Three operations:
throw expr;— raises an exception of the type ofexpr.try { … } catch (T &) { … }— registers a handler for exceptions of typeT(or types derived fromT).throw;(in a catch handler) — re-raises the current exception.
The exception object is conceptually copied (or, since C++11, moved) into a region of memory the runtime maintains; the original throw expression’s lifetime ends. The handler receives a reference to the runtime’s copy.
Stack unwinding and the interaction with destructors
When an exception propagates out of a scope, every automatic-storage-duration object in that scope is destructed in reverse order of construction. The mechanism is stack unwinding; it is the foundation of exception-safe RAII:
void process(const std::string &path) {
std::ifstream input(path); // (1)
std::vector<int> buffer(BUFFER_SIZE); // (2)
std::lock_guard<Mutex> guard(global_mutex); // (3)
if (!input) {
throw std::runtime_error("cannot open " + path); // unwinds 3, 2, 1
}
/* … */
} // ordinary exit: 3, 2, 1 destroyed
If input.is_open() is false and the function throws:
guardis destructed (releases the mutex).bufferis destructed (releases the heap allocation).inputis destructed (closes the file).
The destructions occur in reverse construction order regardless of whether the scope ends by normal return, by return, by throw, or by exception propagation from a callee. The mechanism is what makes RAII safe in the presence of exceptions and is the principal reason the C++ idiom for cleanup uses RAII rather than try/finally (which the language does not provide).
The exception hierarchy
The standard library provides a hierarchy of exception types rooted at std::exception:
std::exception
├─ std::logic_error
│ ├─ std::invalid_argument
│ ├─ std::domain_error
│ ├─ std::length_error
│ └─ std::out_of_range
├─ std::runtime_error
│ ├─ std::range_error
│ ├─ std::overflow_error
│ └─ std::underflow_error
├─ std::bad_alloc
├─ std::bad_cast
├─ std::bad_typeid
└─ std::bad_exception
std::exception::what() returns a const char * describing the exception. The conventional usage:
try {
/* ... */
} catch (const std::exception &e) {
std::cerr << "error: " << e.what() << '\n';
}
User-defined exceptions conventionally derive from std::runtime_error (for environment failures) or std::logic_error (for programming bugs that should arguably be assertions instead):
class ParseError : public std::runtime_error {
public:
ParseError(int line, const std::string &msg)
: std::runtime_error("line " + std::to_string(line) + ": " + msg)
, line_(line)
{}
int line() const noexcept { return line_; }
private:
int line_;
};
The catch hierarchy admits polymorphic catch:
try {
/* ... */
} catch (const ParseError &e) {
/* specific */
} catch (const std::runtime_error &e) {
/* less specific */
} catch (const std::exception &e) {
/* fallback */
}
The catch clauses are matched in source order; the first matching one runs. The conventional discipline is to put the most specific catches first.
noexcept
The noexcept specifier asserts that a function does not throw:
int compute(int x) noexcept; // declares: this never throws
int compute(int x) noexcept(false); // declares: this may throw (the default)
int compute(int x) noexcept(true); // equivalent to bare `noexcept`
template <typename T>
void swap_impl(T &a, T &b) noexcept(std::is_nothrow_move_constructible_v<T>);
A noexcept function that nonetheless throws causes the runtime to call std::terminate; there is no recovery.
The principal effects of marking a function noexcept:
- The standard library’s containers (notably
std::vector) use moves rather than copies during reallocation when the element’s move operations arenoexcept. Withoutnoexcept, the strong exception guarantee forces a fallback to copy. - The optimiser may produce better code, since it does not need to maintain unwinding information for the function.
- Documentation: the function’s contract is that it does not propagate exceptions.
The conventional discipline:
- Mark move constructors and move assignment operators
noexceptwhenever possible. - Mark destructors
noexcept(the default in C++11 and later). - Mark swap functions
noexceptwhenever possible. - Mark accessors and small computations
noexceptif they genuinely do not throw. - Do not mark functions
noexceptthat legitimately may throw; it forces the runtime to terminate on what would otherwise be recoverable.
Exception safety
A function’s exception-safety guarantee describes what state the program is in if the function throws. Three levels are conventional:
| Level | Guarantee |
|---|---|
| Basic | No leaks. Invariants are preserved. The state is some valid state. |
| Strong | No leaks. The state is unchanged: either the function completed or no observable effect occurred. |
| No-throw | The function does not throw. |
The basic guarantee is the minimum acceptable; any function whose state on failure may be inconsistent (leaks, partially-modified objects, broken invariants) violates the guarantee and is a bug.
The strong guarantee is harder: failure leaves the state unchanged. The conventional implementation is the copy-and-swap pattern:
class Resource {
public:
void update(const Configuration &config) {
Resource temp = *this; // copy
temp.do_update(config); // may throw
std::swap(*this, temp); // no-throw swap
}
};
If do_update throws, temp is destroyed and *this is unchanged. The pattern is the conventional way to provide strong exception safety for non-trivial operations.
The no-throw guarantee is the strongest and is required for: destructors, move operations (when realistic), swap functions, and any operation invoked during stack unwinding. Violating the guarantee — throwing from a destructor while another exception is propagating — calls std::terminate.
Error codes versus exceptions
C++ admits both exceptions and value-returned error codes. The choice depends on the failure characteristics:
| Use exceptions for | Use error codes / expected for |
|---|---|
| Rare failures | Frequent failures |
| Constructor failures | Function failures whose recovery is local |
| Failures that propagate through many layers | Failures handled at the immediate caller |
| Failures the typical caller will not handle | Failures the typical caller will handle |
| Programming errors that may also be assertions | Domain errors that are part of the function’s contract |
A function that may fail for typical inputs (a parser, a converter, a search) is typically better served by a value-returned representation:
std::expected<int, ParseError> parse_int(std::string_view s);
std::optional<Element> find(Container &c, const Key &k);
A function whose failure is exceptional and whose recovery is far from the call site (a constructor that allocates and the allocation fails, a network operation that fails for unforeseen reasons) is typically better served by exceptions.
The conventional advice is to choose one mechanism per layer and stay consistent: code that mixes exceptions and error codes for the same kind of failure is harder to read and review than code that picks one.
std::expected
C++23 introduced std::expected<T, E> as a value-level alternative to exceptions:
#include <expected>
std::expected<int, std::string> parse_int(std::string_view s) {
int value = 0;
auto [end, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
if (ec != std::errc{}) {
return std::unexpected{std::string{"parse error"}};
}
return value;
}
auto result = parse_int(input);
if (result) {
use(*result);
} else {
std::cerr << result.error() << '\n';
}
Like std::optional, expected admits monadic chaining (transform, and_then, or_else):
auto squared = parse_int(input)
.transform([](int n) { return n * n; })
.or_else([](auto e) { return std::expected<int, std::string>{0}; });
std::expected is the conventional return type for fallible operations whose error needs more detail than std::optional can carry.
assert and static_assert
assert(cond) (from <cassert>) checks an invariant at runtime; if cond is false, the program prints a message and calls abort:
void process(std::span<const int> data) {
assert(!data.empty());
/* ... */
}
assert is for internal invariants — conditions the program is responsible for. Like in C, defining NDEBUG disables assertions, so they must not be used for environmental checks (file existence, user input validity).
static_assert (C++11) checks a compile-time constant expression:
static_assert(sizeof(int) >= 4, "int must be at least 32 bits");
template <typename T>
void f(T value) {
static_assert(std::is_integral_v<T>, "T must be integral");
}
C++17 admitted a single-argument form: static_assert(cond) produces a generic message on failure. C++26 is expected to admit static_assert(false) in unreachable branches without the message being mandatory.
The cleanup-via-RAII idiom
The C++ analogue of C’s cleanup-label idiom is RAII: every resource is owned by an object whose destructor releases it. The principal C++-specific patterns:
Lock guards
std::mutex m;
int counter = 0;
void increment() {
std::lock_guard<std::mutex> lock(m); // acquires m
++counter;
} // releases m
std::lock_guard (C++11), std::unique_lock, and std::scoped_lock (C++17) are RAII wrappers around mutex operations. std::scoped_lock<M1, M2, ...> acquires multiple mutexes in a deadlock-free order.
File streams
void process(const std::string &path) {
std::ofstream out(path); // opens
if (!out) throw std::runtime_error("cannot open " + path);
out << data;
} // closes
std::ifstream, std::ofstream, and std::fstream are RAII wrappers around file handles. The destructor flushes and closes.
Custom RAII
For resources without a standard wrapper, the conventional pattern is a small RAII type:
class FileDescriptor {
public:
explicit FileDescriptor(int fd) : fd_(fd) {}
~FileDescriptor() { if (fd_ >= 0) ::close(fd_); }
FileDescriptor(const FileDescriptor&) = delete;
FileDescriptor &operator=(const FileDescriptor&) = delete;
FileDescriptor(FileDescriptor &&other) noexcept : fd_(other.fd_) { other.fd_ = -1; }
FileDescriptor &operator=(FileDescriptor &&other) noexcept {
if (this != &other) {
if (fd_ >= 0) ::close(fd_);
fd_ = other.fd_;
other.fd_ = -1;
}
return *this;
}
int get() const { return fd_; }
private:
int fd_;
};
The pattern is the same for every resource: acquire in the constructor, release in the destructor, prevent copying, allow moving. The class encapsulates the cleanup discipline; users get correctness for free.
Common defects
The recurring exception-related defects:
| Defect | Description |
|---|---|
| Throwing from a destructor during unwinding | If an exception is propagating and a destructor throws, std::terminate is called. Destructors must be noexcept (the default in C++11+). |
| Throwing from a constructor in initialiser list | If a constructor throws partway through, only the already-constructed members are destroyed. Each member must be in its own RAII type. |
| Catching by value | catch (std::exception e) slices derived classes; catch by reference (catch (const std::exception &e)). |
| Empty catch clauses | catch (...) {} swallows all errors silently. Either log, re-throw, or do not catch. |
| Mixing exception types | A function that throws int in one path and std::runtime_error in another is harder to handle correctly. Pick one type. |
Marking throw functions noexcept | A noexcept function that throws calls std::terminate. Verify before adding. |
Forgetting noexcept on move operations | Without noexcept, std::vector cannot move during reallocation; falls back to copy. |
The combination of RAII, the standard library’s exception-safe interfaces, and the discipline of marking move operations noexcept produces code that is exception-safe by construction. The cases that remain — destructors that interact with non-trivial resources, partial-construction failure modes — are reduced by careful design rather than by tooling alone.