Skip to content
C++
Language
since C++98
Intermediate

Error Handling Strategies

C++ error handling — exceptions, std::expected, std::error_code, noexcept semantics, exception safety guarantees, and RAII-based cleanup.

Error Handlingsince C++98

C++ 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:

MechanismIntroducedWhen to use
throw / catchC++98Truly exceptional: allocation failure, corrupt data, violated invariants
std::error_codeC++11Typed cross-library errors; Asio-style out-param APIs
std::expected<T,E>C++23Expected failures: parsing, I/O, validation — callers must handle
Raw int/enum codesC++98C 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

cpp
#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

cpp
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

cpp
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.

cpp
// 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.

cpp
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.

cpp
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.

cpp
class ManagedConnection {
public:
    ~ManagedConnection() noexcept {
        try { conn_.shutdown(); }
        catch (...) { /* log; cannot propagate during unwinding */ }
    }
private:
    Connection conn_;
};

Exception Safety Guarantees

LevelGuaranteeTypical mechanism
No-throwNever throws; marked noexceptMove ops, swap, destructors
StrongSucceeds completely or leaves no observable changeCopy-and-swap idiom
BasicObject stays in a valid, usable state; no resource leaksMost standard containers
NoneNo guaranteesAvoid in public APIs
cpp
// 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.

cpp
#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:

cpp
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.

cpp
#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 boundary

std::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 for std::vector to move rather than copy during reallocation.
  • Catch by const reference — 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; bare throw; preserves the original dynamic type and what().
  • Never throw from destructors — mark them noexcept; exceptions arising inside must be swallowed (and logged).
  • Prefer std::expected over bool/std::optional return for fallible operations — the error is self-documenting and monadic chaining keeps the happy path linear.
  • Provide a custom std::error_category when 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:

cpp
catch (std::exception e) { }        // copies, loses derived type
catch (const std::exception& e) { } // correct

Swallowing exceptions with no logging:

cpp
try { risky(); } catch (...) {}   // hides real bugs; at minimum log before swallowing

Using 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:

cpp
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.

cpp
// 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.

cpp
// 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:

MethodTransformsSignature sketch
and_then(f)value → expectedf : T → expected<U,E>
transform(f)value → valuef : T → U
or_else(f)error → expectedf : E → expected<T,F>
transform_error(f)error → errorf : E → F
cpp
// 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.

cpp
// 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.

LevelWhat it promisesHow to achieve it
No-throw (noexcept)Never emits an exception; std::terminate on violationMove ops, swap, destructors, trivial accessors
StrongSucceeds completely, or the program state is unchangedCopy-and-swap; transactional write to a temporary, then commit
BasicNo resource leak; all objects remain in a valid (not necessarily original) stateRAII handles on every resource
NoneNo guarantees whatsoeverAcceptable 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.

cpp
// 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.

cpp
// 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.

cpp
// 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.

cpp
// 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_;
};
cpp
// 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 E in std::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