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.


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