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.
std::error_code and std::error_condition Deep Dive
std::error_code is the lingua franca for typed error propagation across library boundaries. A code bundles an integer value with a pointer to a singleton error_category; two codes are equal only when both the integer and the category match, preventing collisions between libraries that both happen to use the integer 2.
std::error_condition sits one layer above: it represents a portable, abstract condition — such as "file not found" — that can match concrete codes from multiple platform-specific categories. This is how ec == std::errc::no_such_file_or_directory works portably across POSIX, Windows, and custom file-system adapters without a giant switch.
// C++11
#include <system_error>
// Category equivalence check — portable across platforms
std::error_code ec = /* from some OS call */;
if (ec == std::errc::no_such_file_or_directory) {
// true whether ec comes from generic_category() or system_category()
}
// Interoperability: convert between exception and error_code worlds
void api_that_throws(std::string_view host) {
std::error_code ec;
connect_impl(host, ec);
if (ec) throw std::system_error{ec, std::string{host}};
}
void api_noexcept(std::string_view host, std::error_code& ec) noexcept {
try { api_that_throws(host); }
catch (const std::system_error& e) { ec = e.code(); }
catch (...) { ec = std::make_error_code(std::errc::interrupted); }
}Defining a custom category requires exactly one static instance per error domain — the address of that singleton serves as the category identity.
// C++11
enum class AppError { ConfigMissing = 1, SchemaInvalid, QuotaExceeded };
struct AppCategory final : std::error_category {
const char* name() const noexcept override { return "app"; }
std::string message(int v) const override {
switch (static_cast<AppError>(v)) {
case AppError::ConfigMissing: return "configuration file missing";
case AppError::SchemaInvalid: return "schema validation failed";
case AppError::QuotaExceeded: return "resource quota exceeded";
}
return "unknown app error";
}
// Optional: define equivalence with portable conditions
bool equivalent(const std::error_code& code,
int condition) const noexcept override {
if (condition == static_cast<int>(std::errc::invalid_argument))
return code.value() == static_cast<int>(AppError::SchemaInvalid);
return false;
}
};
inline const std::error_category& app_category() noexcept {
static AppCategory inst;
return inst;
}
inline std::error_code make_error_code(AppError e) noexcept {
return {static_cast<int>(e), app_category()};
}
// Make AppError usable directly wherever std::error_code is expected
template<> struct std::is_error_code_enum<AppError> : std::true_type {};std::expected — Monadic Error Propagation (C++23)
std::expected<T, E> turns fallible operations into composable pipelines without the overhead of exceptions or the verbosity of out-parameters. Every call site is statically forced to acknowledge the possibility of failure; forgetting to check triggers a compiler warning or a hard value() throw.
The four monadic operations mirror Rust's Result and Haskell's Either:
| Method | Transforms | Signature sketch |
|---|---|---|
and_then(f) | value → expected | f : T → expected<U,E> |
transform(f) | value → value | f : T → U |
or_else(f) | error → expected | f : E → expected<T,F> |
transform_error(f) | error → error | f : E → F |
// C++23
#include <expected>
#include <charconv>
#include <string_view>
enum class CsvError { BadRow, BadNumber, Overflow };
std::expected<double, CsvError> parse_field(std::string_view cell) {
double v{};
auto [p, ec] = std::from_chars(cell.begin(), cell.end(), v);
if (ec == std::errc::result_out_of_range) return std::unexpected(CsvError::Overflow);
if (ec != std::errc{}) return std::unexpected(CsvError::BadNumber);
return v;
}
std::expected<double, CsvError> validated_field(std::string_view cell) {
return parse_field(cell)
.and_then([](double v) -> std::expected<double, CsvError> {
if (v < 0.0) return std::unexpected(CsvError::BadNumber);
return v;
})
.transform([](double v) { return v * 100.0; }); // scale to basis points
}
// Recover from error at the boundary — provide a sentinel
double price = validated_field(row[3]).value_or(0.0);Chained pipelines stay flat — no nested if (r) { if (r2) { … } } ladders. Error values short-circuit cleanly: once the chain holds an unexpected, all subsequent and_then/transform calls are skipped and the error falls through unchanged.
// C++23 — multi-step pipeline; any step can fail independently
auto result = read_file(path) // expected<string, IoError>
.and_then(parse_json) // expected<Json, ParseError> — different E!
// transform_error bridges the mismatch before and_then
.transform_error([](auto) { return AppError::ConfigMissing; })
// now both arms are AppError
.and_then(validate_schema); // expected<Config, AppError>Exception Safety and noexcept — The Four Levels
Exception safety is a contract about what your function promises to leave behind when an exception escapes it. The four levels form a hierarchy; each level subsumes all weaker ones.
| Level | What it promises | How to achieve it |
|---|---|---|
No-throw (noexcept) | Never emits an exception; std::terminate on violation | Move ops, swap, destructors, trivial accessors |
| Strong | Succeeds completely, or the program state is unchanged | Copy-and-swap; transactional write to a temporary, then commit |
| Basic | No resource leak; all objects remain in a valid (not necessarily original) state | RAII handles on every resource |
| None | No guarantees whatsoever | Acceptable only in private impl details, never on public APIs |
noexcept is not merely a documentation hint — it has measurable runtime consequences. std::vector calls std::move_if_noexcept during reallocation: if the element type's move constructor is not noexcept, the standard mandates copying instead of moving to preserve the strong guarantee.
// C++11
struct HeavyResource {
std::vector<std::byte> data;
// Without noexcept: vector copies 'data' on every reallocation — O(n) per push_back
// With noexcept: vector moves 'data' — O(1) per push_back
HeavyResource(HeavyResource&&) noexcept = default;
HeavyResource& operator=(HeavyResource&&) noexcept = default;
};
// Verify at compile-time that the guarantee holds
static_assert(std::is_nothrow_move_constructible_v<HeavyResource>);
static_assert(std::is_nothrow_move_assignable_v<HeavyResource>);The conditional noexcept idiom propagates the guarantee through generic wrappers without hard-coding assumptions about template arguments.
// C++11
template<typename T>
class Stack {
public:
void push(T val)
noexcept(std::is_nothrow_move_constructible_v<T>) {
items_.push_back(std::move(val));
}
T pop() noexcept(std::is_nothrow_move_constructible_v<T>) {
T top = std::move(items_.back());
items_.pop_back();
return top;
}
private:
std::vector<T> items_;
};Error Handling in Constructors
Constructors cannot return a value, so the only mechanism for signalling failure is either throwing an exception or deferring the work to a factory function. The two-phase construction anti-pattern — constructing a zombie object and calling an init() method — is dangerous because users can forget to call init(), and there is no way to enforce the call order at compile time.
// AVOID: two-phase construction — the object may be used before init()
class Connection {
public:
Connection() = default; // constructs a zombie
bool init(std::string_view url); // must be called; easily forgotten
void query(std::string_view sql); // undefined if init() not called first
};The preferred patterns are: throw from the constructor (so the object never exists in an invalid state), or use a static factory that returns std::expected when exceptions are disabled.
// C++11 — throw from constructor; the object is never half-alive
class Connection {
public:
explicit Connection(std::string_view url) {
handle_ = open_socket(url);
if (!handle_)
throw std::system_error{
std::make_error_code(std::errc::connection_refused), std::string{url}};
}
// handle_ is always valid if Connection exists
private:
SocketHandle handle_;
};// C++23 — factory function for exception-free environments
class Connection {
public:
static std::expected<Connection, std::error_code>
create(std::string_view url) noexcept {
SocketHandle h = open_socket(url);
if (!h) return std::unexpected(
std::make_error_code(std::errc::connection_refused));
return Connection{std::move(h)}; // private ctor — always valid
}
private:
explicit Connection(SocketHandle h) noexcept : handle_{std::move(h)} {}
SocketHandle handle_;
};
// Usage: caller forced to handle failure before accessing the object
auto conn = Connection::create("db.internal:5432");
if (!conn) { /* handle error */ return; }
conn->query("SELECT 1");Mixing strategies is common: in exception-enabled code the constructor throws; in embedded or real-time code the factory returns std::expected. Provide both if your library targets both environments.
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