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

Exception handling

C++ exception handling — throw, try/catch, noexcept, exception safety guarantees, stack unwinding, and RAII-based resource safety.

Exception handlingsince C++98

A structured mechanism for separating error detection from error recovery: a throw expression transfers control to the nearest enclosing catch handler that matches the thrown type, unwinding the stack and destroying all local objects along the way.

Overview

C++ exceptions are not a simple setjmp/longjmp mechanism — they guarantee deterministic stack unwinding: every automatic object constructed between the throw site and the matching catch has its destructor called in reverse construction order. This is the foundation of RAII-based resource safety.

Three orthogonal concerns dominate exception-correct C++:

  1. What to throw and catch — type hierarchy, catch ordering, slicing
  2. noexcept contracts — move-safety, optimizer hints, std::terminate traps
  3. Exception safety levels — basic, strong, no-throw guarantees

C++11 significantly reshaped the landscape: noexcept replaced the deprecated dynamic exception specifications (throw(...)), and std::exception_ptr enabled cross-thread exception transport.

Syntax

cpp
// Throw — any copyable (C++98) or movable (C++11) type
throw std::runtime_error("message");
throw SomeUserType{};

// Try/catch block
try {
    // code that may throw
} catch (const DerivedError& e) {   // most-derived first
    // handle
} catch (const std::exception& e) { // base class catch
    // handle
} catch (...) {                     // catch-all
    throw;                          // always rethrow if you can't handle
}

// noexcept specifier (C++11)
void f() noexcept;                          // never throws
void g() noexcept(false);                   // may throw (explicit)
template<typename T>
void h(T& a, T& b) noexcept(noexcept(a.swap(b)));  // conditional

// noexcept operator — compile-time query (C++11)
static_assert(noexcept(std::declval<int&>() = 0));

Dynamic exception specifications (throw(X)) were deprecated in C++11 and removed in C++17. Never write them.

Examples

Custom exception hierarchy

cpp
#include <stdexcept>
#include <system_error>
#include <string>

// Derive from runtime_error or logic_error, not directly from exception
struct DatabaseError : std::runtime_error {
    explicit DatabaseError(std::string_view msg, int code)
        : std::runtime_error(std::string(msg)), error_code(code) {}
    int error_code;
};

struct ConnectionError : DatabaseError {
    ConnectionError(std::string_view host, int code)
        : DatabaseError("connection failed: " + std::string(host), code)
        , host(host) {}
    std::string host;
};

Correct catch ordering

cpp
void handle_db_op() {
    try {
        connect_and_query();
    }
    catch (const ConnectionError& e) {      // most derived first
        retry_with_fallback(e.host);
    }
    catch (const DatabaseError& e) {        // base class second
        log_db_failure(e.error_code);
        throw;                              // rethrow — don't swallow
    }
    catch (const std::exception& e) {
        // last resort for unexpected std exceptions
        log_unexpected(e.what());
        throw;
    }
    // omit catch(...) unless you can do something useful
}

noexcept and move semantics

std::vector must choose between move and copy when reallocating. It uses move only if the move constructor is noexcept; otherwise it falls back to copy for the strong exception-safety guarantee. This is the single most impactful place noexcept matters for performance:

cpp
class Buffer {
    std::unique_ptr<char[]> data_;
    std::size_t size_;
public:
    Buffer(Buffer&&) noexcept = default;            // enables vector move
    Buffer& operator=(Buffer&&) noexcept = default;
    // Without noexcept, std::vector<Buffer>::push_back copies instead of moves
};

// Check with type trait (C++11)
static_assert(std::is_nothrow_move_constructible_v<Buffer>); // C++17 _v alias

Exception safety via copy-and-swap

The strong guarantee means: either the operation completes fully, or the program state is exactly as it was before. Copy-and-swap achieves this:

cpp
class Config {
    std::map<std::string, std::string> entries_;
public:
    // Strong guarantee: if parse() throws, entries_ is unchanged
    void reload(std::string_view raw) {
        auto tmp = parse(raw);       // may throw — entries_ untouched
        entries_ = std::move(tmp);  // noexcept swap-like assignment
    }
};

The key insight: do all the work that can throw on a temporary; commit by swapping or moving into the real state only after success.

Cross-thread exception transport (C++11)

std::exception_ptr is a nullable, copyable handle to a thrown exception object. It enables transferring exceptions across thread boundaries — the primary use case is collecting worker thread failures in the main thread.

cpp
#include <exception>
#include <thread>
#include <vector>
#include <stdexcept>

std::exception_ptr worker_exception;

void worker() {
    try {
        do_heavy_work();
    } catch (...) {
        worker_exception = std::current_exception(); // C++11
    }
}

int main() {
    std::thread t(worker);
    t.join();
    if (worker_exception) {
        std::rethrow_exception(worker_exception); // C++11 — rethrows in main
    }
}

std::make_exception_ptr<E>(e) (C++11) creates an exception_ptr without needing a live catch block.

Standard exception hierarchy

cpp
std::exception
├── std::logic_error          — violated precondition, detectable before runtime
│   ├── std::invalid_argument
│   ├── std::domain_error
│   ├── std::length_error
│   └── std::out_of_range
├── std::runtime_error        — errors only detectable at runtime
│   ├── std::range_error
│   ├── std::overflow_error
│   ├── std::underflow_error
│   └── std::system_error     — C++11; carries std::error_code
│       └── std::ios_base::failure (reclassified in C++11)
├── std::bad_alloc            — operator new failure
│   └── std::bad_array_new_length  — C++11
├── std::bad_cast             — dynamic_cast<T&> failure
├── std::bad_typeid           — typeid on null pointer
├── std::bad_exception        — unexpected() result (largely obsolete)
├── std::bad_optional_access  — C++17
├── std::bad_variant_access   — C++17
└── std::bad_function_call    — C++11

Use std::system_error (C++11) when wrapping OS or POSIX errors — it carries an std::error_code that preserves the original error category and value without losing information.

Best Practices

Throw by value, catch by const reference. Throwing a temporary is fine; the runtime copies it into exception storage. Catching by value triggers slicing — the derived type's fields and virtual dispatch are lost.

Derive custom exceptions from std::exception subclasses, not directly from std::exception. Deriving from std::runtime_error or std::logic_error gives you a constructor that accepts a string message and a working what() implementation for free.

Mark move constructors and swap noexcept. The standard library containers and algorithms assume these operations don't throw. Failing to annotate them forces the library into less-efficient copy paths.

Prefer the strong guarantee over the basic guarantee. The basic guarantee (no leaks, invariants hold) is the minimum bar. The strong guarantee (commit-or-rollback) makes code composable — callers can retry or fall back without inspecting partially-modified state.

Rethrow with bare throw;, never throw e;. throw e; copies the exception object, slicing it to the declared type of e. Bare throw; rethrows the original exception object with its original dynamic type.

Common Pitfalls

Slicing in catch clauses

cpp
catch (std::exception e) {   // copies and slices the thrown object
    e.what();                 // may return base class message, not derived
}
// Fix:
catch (const std::exception& e) { }

Destructors that throw

If a destructor throws during stack unwinding (while another exception is already propagating), std::terminate is called immediately. Since C++11, destructors are implicitly noexcept — but if you explicitly define one that can throw, you have undefined behavior in two-exception scenarios. Never let exceptions escape destructors.

noexcept calling potentially-throwing code

noexcept does not prevent exceptions from being thrown inside the function body — it causes std::terminate if one propagates out. Annotating a function noexcept when it internally calls throwing code is legal only if you guarantee those paths are unreachable at runtime.

cpp
void dangerous() noexcept {
    risky_operation();  // if this throws, std::terminate() — not a handler
}

Exceptions and constructors

If a constructor throws, the destructor is not called for that object (it was never fully constructed). However, destructors for all fully-constructed base classes and member subobjects are called. Use member initializer lists with RAII members — not raw new in the constructor body — to avoid partial-construction leaks.

Using exceptions for expected control flow

Exception handling has near-zero cost on the happy path (no-exception path) in most implementations, but the cost of actually throwing and catching can be hundreds of nanoseconds to microseconds due to stack unwinding. Use std::optional, std::expected (C++23), or error codes for expected failure modes like "key not found" or "parse failed on user input."

Exception Hierarchy Design

Design domain exception hierarchies so the most derived type always carries the most specific information. Catch handlers should mirror this specificity: place the most-derived catch first, because the first matching handler wins and a base-class catch placed first will shadow all its derived siblings.

cpp
// C++11
#include <stdexcept>
#include <string>

struct AppError : std::runtime_error {
    explicit AppError(std::string_view msg) : std::runtime_error(std::string(msg)) {}
};

struct DatabaseError : AppError {
    int sql_code;
    DatabaseError(std::string_view msg, int code)
        : AppError(msg), sql_code(code) {}
};

struct NetworkError : AppError {
    explicit NetworkError(std::string_view msg) : AppError(msg) {}
};

struct ValidationError : AppError {
    std::string field;
    ValidationError(std::string_view field, std::string_view msg)
        : AppError(msg), field(field) {}
};

void process() {
    try {
        run_pipeline();
    }
    catch (const DatabaseError& e) {   // most derived first
        handle_db(e.sql_code);
    }
    catch (const NetworkError& e) {
        retry_later();
    }
    catch (const AppError& e) {        // base class after all derived
        log_generic(e.what());
        throw;
    }
}

When one layer catches an exception and needs to add context before re-throwing, std::throw_with_nested wraps the active exception inside a new one. The receiver can peel the layers apart with std::rethrow_if_nested.

cpp
// C++11
#include <exception>
#include <stdexcept>

void query_db() {
    try {
        low_level_query();
    } catch (...) {
        // Wraps current exception inside DatabaseError; original is nested
        std::throw_with_nested(DatabaseError("query failed during checkout", 500));
    }
}

void print_exception(const std::exception& e, int depth = 0) {
    std::cerr << std::string(depth * 2, ' ') << e.what() << '\n';
    try {
        std::rethrow_if_nested(e);
    } catch (const std::exception& nested) {
        print_exception(nested, depth + 1);
    }
}

std::exception_ptr: Transferring Exceptions Across Threads

std::exception_ptr is a nullable, copyable, reference-counted handle to a thrown exception object that outlives the catch block that captured it. std::current_exception() captures the currently-active exception into an exception_ptr; std::rethrow_exception(ep) re-throws it in any thread at any later time.

cpp
// C++11
#include <exception>
#include <stdexcept>
#include <thread>
#include <vector>
#include <iostream>

std::exception_ptr g_worker_ex;

void worker() {
    try {
        do_heavy_computation();
    } catch (...) {
        g_worker_ex = std::current_exception(); // capture, don't lose the exception
    }
}

int main() {
    std::thread t(worker);
    t.join();                          // wait for worker to finish

    if (g_worker_ex) {
        try {
            std::rethrow_exception(g_worker_ex); // re-throw in main thread
        } catch (const std::exception& e) {
            std::cerr << "Worker failed: " << e.what() << '\n';
        }
    }
}

This is exactly the mechanism std::future::get() uses internally: the worker stores the exception via std::current_exception() and the calling thread rethrows it inside get(). std::make_exception_ptr<E>(e) (C++11) creates an exception_ptr directly without needing an active exception, which is useful for pre-constructing error states.

terminate, unexpected, and Unwinding

std::terminate is the synchronous, unrecoverable error path — it is not an exception and cannot be caught. It is invoked in several situations: a noexcept function lets an exception escape, a destructor throws during stack unwinding (when another exception is already propagating), a pure virtual function is called from a constructor or destructor, or a thread's top-level function exits via an uncaught exception.

cpp
// C++17
#include <exception>
#include <iostream>

// Install a custom handler before any exceptions are thrown
std::terminate_handler old_handler =
    std::set_terminate([]() noexcept {
        std::cerr << "[fatal] std::terminate called — aborting\n";
        std::abort();
    });

C++17 introduced std::uncaught_exceptions() (plural), returning the number of currently-propagating exceptions. The C++03-era std::uncaught_exception() (singular, bool) was insufficient for scope guards nested inside other scope guards, because it could not distinguish "am I inside the first unwind?" from "am I inside the second?". Scope guards that must not throw on unwind use the count to decide:

cpp
// C++17
#include <exception>

struct ScopeGuard {
    int exception_count_ = std::uncaught_exceptions();

    ~ScopeGuard() noexcept(false) {
        bool unwinding = std::uncaught_exceptions() > exception_count_;
        if (!unwinding) {
            commit(); // safe to throw — we are on the normal exit path
        }
        // if unwinding, silently skip to avoid terminate
    }
};

Stack unwinding is two-phase in the Itanium ABI (used by GCC and Clang on Linux): phase 1 scans the stack searching for a matching handler without running any destructors; phase 2 runs destructors and transfers control to the handler. MSVC uses a table-driven model that is conceptually similar. The practical implication is that __cxa_throw (or equivalent) incurs measurable cost only on the exception path; the no-exception path is essentially free in terms of runtime overhead.

Exception-Free Alternatives: std::expected and Error Codes

Choose your error-signalling mechanism based on whether failure is truly exceptional, how hot the path is, and whether the code crosses C or OS boundaries. The three primary tools have different trade-offs:

MechanismBest forNot for
throw / catchTruly exceptional failures, cross-layer propagation, rich type hierarchyHot loops, C interop, noexcept contexts
std::expected<T,E> (C++23)Normal failure paths, hot paths, functional compositionLegacy codebases, widespread C interop
std::error_codeOS/POSIX APIs, C interop, low-level librariesRich error context, easy composition
cpp
// C++23
#include <expected>
#include <system_error>
#include <string>

// Layer 1: OS call returns error_code
std::error_code read_file_raw(std::string_view path, std::string& out);

// Layer 2: mid-tier converts to expected for ergonomic composition
std::expected<std::string, std::error_code> read_file(std::string_view path) {
    std::string buf;
    auto ec = read_file_raw(path, buf);
    if (ec) return std::unexpected(ec);
    return buf;
}

// Layer 3: top-level converts to exception for cross-layer propagation
std::string load_config(std::string_view path) {
    auto result = read_file(path);
    if (!result) {
        throw std::system_error(result.error(), std::string(path));
    }
    return std::move(*result);
}

Converting at layer boundaries keeps each layer in its natural idiom: OS glue uses error_code, library internals use expected for zero-cost composability, and application logic uses exceptions for cross-cutting error propagation. Avoid mixing idioms within a single layer — inconsistency costs more than any individual choice.

Structured Exceptions on Windows (SEH)

SEH (__try/__except/__finally) is a Windows-kernel-level mechanism distinct from C++ exceptions, though MSVC implements C++ exceptions on top of SEH internally. SEH can catch hardware faults (access violations, division by zero) that C++ exceptions cannot; C++ exceptions cannot be caught by __except filters and SEH exceptions cannot be caught by C++ catch blocks unless explicitly bridged.

cpp
// Windows/MSVC only — not portable
#include <windows.h>
#include <eh.h>

// Translate SEH exceptions into C++ exceptions (/EHa flag required)
class SehException : public std::runtime_error {
public:
    explicit SehException(unsigned int code)
        : std::runtime_error("SEH exception"), seh_code(code) {}
    unsigned int seh_code;
};

void seh_translator(unsigned int code, EXCEPTION_POINTERS*) {
    throw SehException(code);
}

void setup_seh_bridge() {
    _set_se_translator(seh_translator); // must be called per-thread
}

The MSVC /EHs flag (the default) enables C++ exception handling with the assumption that extern "C" functions do not throw; /EHa additionally allows catching SEH exceptions with C++ catch(...). Mixing SEH and C++ exceptions is hazardous: RAII destructors are not guaranteed to run for SEH exceptions under /EHs, and the interaction with stack unwinding is implementation-defined. Prefer C++ exceptions for all portable code; use SEH only when directly handling hardware faults or integrating with Windows system callbacks that mandate it.

Exceptions in Constructors and Destructors

If a constructor throws, the object is considered never to have existed: its own destructor will not be called, but destructors for all fully-constructed base classes and direct member subobjects are called automatically in reverse order. This means raw-pointer members acquired before the throw will leak unless wrapped in RAII types.

cpp
// C++11
#include <memory>
#include <stdexcept>

class Connection {
    std::unique_ptr<Socket> socket_;  // destroyed automatically if ctor throws
    std::unique_ptr<Buffer> buf_;     // destroyed automatically if ctor throws
public:
    explicit Connection(std::string_view host) {
        socket_ = std::make_unique<Socket>(host); // may throw; buf_ not yet constructed
        buf_    = std::make_unique<Buffer>(4096);  // may throw; socket_ cleaned up
        // If anything throws here, both unique_ptrs are destroyed — no leak
    }
};

A function-try-block on a constructor allows catching exceptions thrown by base or member initializers — the only way to intercept errors in the member initializer list. The exception must be re-thrown (explicitly or implicitly) because the partially-constructed object cannot be returned.

cpp
// C++11
class Widget : public Base {
    Resource res_;
public:
    Widget()
    try : Base(), res_("init")          // exceptions here are catchable
    {
        // constructor body
    }
    catch (const std::exception& e) {
        // Log, translate, or rethrow — but cannot suppress the exception
        throw std::runtime_error(std::string("Widget init failed: ") + e.what());
    }
};

Destructors must not allow exceptions to escape. Since C++11 all destructors are implicitly noexcept; if a destructor body throws and that exception propagates out, the program calls std::terminate. The correct pattern is to catch and swallow (or log) inside the destructor:

cpp
// C++11
class ManagedFile {
    FILE* fp_;
public:
    ~ManagedFile() noexcept {
        if (fp_) {
            try {
                flush_and_close(fp_); // hypothetical throwing close
            } catch (const std::exception& e) {
                log_error("close failed: ", e.what()); // swallow — never propagate
            }
        }
    }
};

See Also

  • RAII — the idiom that makes exception safety achievable
  • noexcept — contract annotation and move-safety
  • error-handling — std::expected (C++23) and alternatives
  • std::optional — C++17 nullable return without exceptions