Error Handling Strategies
C++ error handling — exceptions, std::expected, std::error_code, noexcept semantics, exception safety guarantees, and RAII-based cleanup.
Error Handlingsince C++98C++ provides three complementary mechanisms — exceptions for truly exceptional conditions, std::error_code (C++11) for typed cross-library propagation, and std::expected<T,E> (C++23) for operations where failure is an expected part of the contract.
Overview
Choosing the wrong mechanism produces either silently ignored errors (unchecked codes), unnecessary overhead (exceptions on the hot path), or impenetrable call sites. The decision matrix:
| Mechanism | Introduced | When to use |
|---|---|---|
throw / catch | C++98 | Truly exceptional: allocation failure, corrupt data, violated invariants |
std::error_code | C++11 | Typed cross-library errors; Asio-style out-param APIs |
std::expected<T,E> | C++23 | Expected failures: parsing, I/O, validation — callers must handle |
Raw int/enum codes | C++98 | C interop, embedded/no-exception environments, tight inner loops |
If the immediate caller can reasonably handle the failure inline (file not found, parse error, validation), use std::expected or error codes. Reserve exceptions for conditions the caller cannot meaningfully recover from — a failed heap allocation, a protocol invariant violation, an unrecoverable hardware fault.
Exceptions
Mechanics
#include <stdexcept>
#include <fstream>
void load_config(const std::string& path) {
std::ifstream f{path};
if (!f)
throw std::runtime_error("cannot open: " + path); // throw by value
}
// Catch most-derived first; catch by const reference (never by value — slices)
try {
load_config("app.toml");
} catch (const std::system_error& e) { // C++11, more specific — first
std::println("system [{}]: {}", e.code().value(), e.what());
} catch (const std::runtime_error& e) {
std::println("runtime: {}", e.what());
} catch (const std::exception& e) {
std::println("error: {}", e.what());
} catch (...) {
std::println("unknown exception");
}
// Rethrow — bare throw; preserves exception identity and type
// throw e; would copy and reset — loses derived type
try {
risky();
} catch (...) {
cleanup();
throw;
}Standard exception hierarchy
std::exception
├── std::logic_error — programming errors; preconditions violated
│ ├── std::invalid_argument
│ ├── std::domain_error
│ ├── std::out_of_range
│ └── std::length_error
├── std::runtime_error — conditions outside program control
│ ├── std::overflow_error / std::underflow_error / std::range_error
│ └── std::system_error (C++11, wraps std::error_code)
├── std::bad_alloc — operator new failed
├── std::bad_cast — dynamic_cast<T&> on wrong type
├── std::bad_variant_access (C++17)
└── std::bad_optional_access (C++17)Custom exception types
class DatabaseError : public std::runtime_error {
public:
DatabaseError(std::string_view msg, int sql_code)
: std::runtime_error(std::string{msg}), sql_code_{sql_code} {}
int sql_code() const noexcept { return sql_code_; }
private:
int sql_code_;
};
// Throw by value; derived exceptions should inherit from a std:: base
throw DatabaseError("connection refused", 2003);
try { execute_query(sql); }
catch (const DatabaseError& e) {
log_error("DB [{}]: {}", e.sql_code(), e.what());
}noexcept — C++11
noexcept is a contract: if the function throws anyway, std::terminate fires immediately — no unwinding, no catch. Use it on operations that genuinely cannot fail.
// Absolute guarantee — std::terminate on any violation
int safe_div(int a, int b) noexcept {
if (b == 0) return 0;
return a / b;
}
// Conditional noexcept — propagate the inner type's guarantee
template<typename T>
void swap_vals(T& a, T& b)
noexcept(std::is_nothrow_move_constructible_v<T> && // C++11
std::is_nothrow_move_assignable_v<T>)
{
T tmp = std::move(a);
a = std::move(b);
b = std::move(tmp);
}Critical rule for std::vector performance: since C++11, vector uses std::move_if_noexcept during reallocation. If your type's move constructor is not noexcept, vector falls back to copying every element to maintain the strong exception guarantee — paying O(n) copy cost instead of O(n) move cost on every reallocation.
struct Widget {
Widget(Widget&&) noexcept = default; // enables O(n) moves in vector
Widget& operator=(Widget&&) noexcept = default;
// ...
};Stack Unwinding and RAII
When an exception propagates, the runtime destroys every local variable with a destructor in each stack frame it unwinds through. RAII handles are the correct response to cleanup — not try/catch blocks.
void process(const std::string& path) {
// Destroyed in reverse order whether we return normally or throw
auto file = std::unique_ptr<FILE, decltype(&fclose)>{
fopen(path.c_str(), "r"), &fclose
};
if (!file)
throw std::system_error{errno, std::generic_category(), path}; // C++11
std::lock_guard lock{mutex_}; // C++17 CTAD
// Any exception here: lock released, file closed automatically
parse(*file);
}Never let an exception escape a destructor. During stack unwinding a second active exception calls std::terminate. Mark destructors noexcept (the implicit default since C++11) and swallow anything that arises inside them.
class ManagedConnection {
public:
~ManagedConnection() noexcept {
try { conn_.shutdown(); }
catch (...) { /* log; cannot propagate during unwinding */ }
}
private:
Connection conn_;
};Exception Safety Guarantees
| Level | Guarantee | Typical mechanism |
|---|---|---|
| No-throw | Never throws; marked noexcept | Move ops, swap, destructors |
| Strong | Succeeds completely or leaves no observable change | Copy-and-swap idiom |
| Basic | Object stays in a valid, usable state; no resource leaks | Most standard containers |
| None | No guarantees | Avoid in public APIs |
// Strong guarantee via copy-and-swap
class Buffer {
public:
// Pass by value: copy (or move) happens before entry and may throw.
// swap is noexcept — so the commit is atomic.
Buffer& operator=(Buffer other) noexcept {
swap(*this, other);
return *this;
} // old data destroyed by `other`'s destructor
friend void swap(Buffer& a, Buffer& b) noexcept {
using std::swap;
swap(a.data_, b.data_);
swap(a.size_, b.size_);
}
private:
std::byte* data_{};
std::size_t size_{};
};std::expected (C++23)
std::expected<T, E> holds either a value T or an error E. Unlike std::optional, it carries a reason for absence. Unlike exceptions, it is zero-overhead on the success path on most implementations and forces caller acknowledgement.
#include <expected> // C++23
enum class ParseError { Empty, InvalidChar, Overflow };
std::expected<int, ParseError> parse_int(std::string_view s) {
if (s.empty()) return std::unexpected(ParseError::Empty);
int result{};
auto [ptr, ec] = std::from_chars(s.begin(), s.end(), result); // C++17
if (ec == std::errc::result_out_of_range)
return std::unexpected(ParseError::Overflow);
if (ec != std::errc{})
return std::unexpected(ParseError::InvalidChar);
return result;
}
// Direct access
auto r = parse_int("42");
if (r) std::println("value: {}", *r); // operator* on success
else std::println("error: {}", static_cast<int>(r.error()));
// Monadic chaining — compose without manual unwrapping
auto result = parse_int("100")
.and_then([](int n) -> std::expected<int, ParseError> {
if (n > 1'000'000) return std::unexpected(ParseError::Overflow);
return n * 2;
})
.transform([](int n) { return n + 1; }) // map over value
.transform_error([](ParseError) { // map over error
return ParseError::InvalidChar;
})
.value_or(-1);
// or_else — recover from error, provide fallback
auto safe = parse_int("bad")
.or_else([](ParseError) -> std::expected<int, ParseError> {
return 0;
});For multi-failure-mode APIs, use an error variant:
using DbError = std::variant<TimeoutError, ConnectionError, QueryError>;
std::expected<Row, DbError> fetch(std::string_view sql);std::error_code (C++11)
std::error_code pairs an integer with an std::error_category singleton — providing typed, portable error propagation across library boundaries without requiring exceptions.
#include <system_error> // C++11
// From errno / POSIX
std::error_code ec = std::make_error_code(std::errc::no_such_file_or_directory);
std::println("{}: {} — {}", ec.category().name(), ec.value(), ec.message());
// → generic: 2 — No such file or directory
// Custom category — one static instance per error domain
enum class NetError { Timeout = 1, Refused, Unreachable };
struct NetCategory final : std::error_category {
const char* name() const noexcept override { return "net"; }
std::string message(int v) const override {
switch (static_cast<NetError>(v)) {
case NetError::Timeout: return "connection timed out";
case NetError::Refused: return "connection refused";
case NetError::Unreachable: return "network unreachable";
}
return "unknown net error";
}
};
const std::error_category& net_category() noexcept {
static NetCategory instance;
return instance;
}
std::error_code make_net_error(NetError e) noexcept {
return {static_cast<int>(e), net_category()};
}
// Out-parameter style (Asio convention) — noexcept, never throws
void connect(std::string_view host, std::error_code& ec) noexcept {
if (host.empty()) { ec = std::make_error_code(std::errc::invalid_argument); return; }
ec.clear();
// ...
}
std::error_code ec;
connect("db.internal", ec);
if (ec) throw std::system_error{ec, "connect"}; // convert to exception at boundarystd::error_condition (also C++11) represents a portable, abstract condition that can match multiple concrete error_code values from different categories. Comparisons like ec == std::errc::no_such_file_or_directory work through error_condition equivalence, not integer equality.
Best Practices
- Mark move constructors and move assignment
noexcept— required forstd::vectorto move rather than copy during reallocation. - Catch by
constreference — catching by value slices derived exceptions;catch (std::exception e)silently loses the concrete type. - Rethrow with bare
throw;—throw e;copies the exception and resets it to the static type; barethrow;preserves the original dynamic type andwhat(). - Never throw from destructors — mark them
noexcept; exceptions arising inside must be swallowed (and logged). - Prefer
std::expectedoverbool/std::optionalreturn for fallible operations — the error is self-documenting and monadic chaining keeps the happy path linear. - Provide a custom
std::error_categorywhen exposing library errors — raw integers leak implementation detail across boundaries and have no portable meaning. - Document exception guarantees on public APIs — at minimum, state whether each function is no-throw, strong, or basic.
Common Pitfalls
Catching by value — silently slices:
catch (std::exception e) { } // copies, loses derived type
catch (const std::exception& e) { } // correctSwallowing exceptions with no logging:
try { risky(); } catch (...) {} // hides real bugs; at minimum log before swallowingUsing std::expected for programming errors — if a null pointer or violated precondition reaches your function, that is a bug, not an expected failure. Assert or throw; do not force callers to check an error they can never meaningfully handle.
Not checking std::error_code out-parameters:
std::error_code ec;
std::filesystem::copy(src, dst, ec);
// failing to check ec — error silently discarded
if (ec) throw std::system_error{ec, "copy"};Conflating std::error_code and std::error_condition — do not compare error_code values from two different categories using integer equality; use the == operator which routes through error_condition equivalence for portable cross-platform checks.
See Also
- RAII — resource management via destructors; the foundation of exception-safe code
- std::optional — absent values without an error reason; not a substitute for error propagation
- Concepts — constrain the error type
Einstd::expected<T, E>template parameters - Coroutines — coroutine frames suspend rather than unwind; propagate errors via
std::expected-returning awaitables rather than exceptions across suspension points