Skip to main content
C++ intermediate Lesson 18 of 23

Error Handling in C++

Handle errors with exceptions, noexcept, RAII for resource safety, and std::expected from C++23.

C++ offers several error handling strategies, and choosing the right one matters for both correctness and performance. Exceptions work well for rare, unexpected failures — they propagate automatically through call stacks without touching every intermediate function. Error codes work better when failures are common and expected — parsing user input, looking up a key that might not exist. std::expected (C++23) provides a monadic middle ground that makes the error path explicit in the type system without the overhead of exceptions. Understanding all three and when to reach for each is what separates robust C++ from fragile C++.

Exceptions: try, catch, throw

Exceptions let you separate error detection (deep in a call stack) from error handling (wherever the caller that knows what to do about it lives). Without exceptions, every intermediate function in between would need to check and forward an error code. With exceptions, the error propagates automatically until caught.

#include <stdexcept>
#include <string>
#include <fstream>

std::string read_file(const std::string& path) {
    std::ifstream file(path);
    if (!file.is_open())
        throw std::runtime_error("cannot open file: " + path);

    return std::string(
        std::istreambuf_iterator<char>(file),
        std::istreambuf_iterator<char>()
    );
}

int main() {
    try {
        auto contents = read_file("config.json");
        // process contents
    } catch (const std::runtime_error& e) {
        std::cerr << "Error: " << e.what() << "\n";
    } catch (const std::exception& e) {
        std::cerr << "Unexpected: " << e.what() << "\n";
    } catch (...) {
        std::cerr << "Unknown exception\n";
    }
}

Catch by const reference to avoid slicing. The most-derived type in the hierarchy should come first — catching std::exception before std::runtime_error would swallow the more specific handler.

The Standard Exception Hierarchy

The standard library provides a well-organized hierarchy. Using the right base class communicates whether the error is a programming mistake or a runtime condition, which helps callers decide how to handle it.

std::exception
├── std::logic_error        — bugs detectable before runtime (violated preconditions)
│   ├── std::invalid_argument
│   ├── std::out_of_range
│   └── std::length_error
└── std::runtime_error      — errors detected at runtime (I/O, network, OS)
    ├── std::overflow_error
    ├── std::underflow_error
    └── std::system_error

Throw std::logic_error subtypes for programming errors (invalid arguments, precondition violations). Throw std::runtime_error subtypes for conditions outside the program’s control (I/O failure, network timeout).

Custom Exception Classes

Deriving from the standard hierarchy lets callers catch your exceptions generically with catch (const std::exception&) while still allowing fine-grained handling when they want it. Carry enough context in the exception for the handler to make a good decision.

#include <stdexcept>
#include <string>

class DatabaseError : public std::runtime_error {
    int error_code_;
    std::string query_;
public:
    DatabaseError(int code, std::string query, std::string msg)
        : std::runtime_error(std::move(msg))
        , error_code_(code)
        , query_(std::move(query)) {}

    int error_code() const noexcept { return error_code_; }
    const std::string& query() const noexcept { return query_; }
};

class ConnectionError : public DatabaseError {
public:
    ConnectionError(std::string host)
        : DatabaseError(1001, "", "cannot connect to " + host) {}
};

// Catch the base class to handle the whole family, or the specific type for finer control
try {
    run_query("SELECT * FROM users");
} catch (const ConnectionError& e) {
    reconnect();  // specific handling for connection failures
} catch (const DatabaseError& e) {
    log_error(e.error_code(), e.what());  // general DB error handling
}

Exception Safety Guarantees

Every function that can throw should document which guarantee it provides. This is part of the function’s contract and determines how callers can safely use it.

  • nothrow: The function never throws. Guaranteed by noexcept.
  • strong: If the function throws, program state is unchanged (like a database transaction). Implement via copy-then-swap.
  • basic: If the function throws, the program is in a valid (but possibly modified) state. No resources are leaked.
  • none: Throwing may leave resources leaked or state corrupted. Never acceptable in production code.
// Strong guarantee via copy-and-swap: if parse() throws, data_ is untouched
class Config {
    std::map<std::string, std::string> data_;
public:
    void load(const std::string& path) {
        auto tmp = data_;          // copy current state
        parse_into(tmp, path);     // may throw — modifies tmp, not data_
        data_ = std::move(tmp);    // nothrow commit — only reached if parse succeeded
    }

private:
    void parse_into(std::map<std::string, std::string>& dest,
                    const std::string& path);
};

noexcept — Telling the Compiler a Function Won’t Throw

noexcept is a promise to the compiler and to callers. The compiler can generate more efficient code — it skips setting up stack unwinding infrastructure. More importantly, std::vector and other containers only use your move constructor during reallocation if it is noexcept; otherwise they fall back to copying. Always mark move constructors, destructors, and swap functions noexcept.

// noexcept tells callers and the compiler this function never throws
int safe_divide(int a, int b) noexcept {
    if (b == 0) return 0;  // handle the error case without throwing
    return a / b;
}

// noexcept(expr) — conditionally noexcept based on whether the expression is noexcept
template<typename T>
void swap_values(T& a, T& b) noexcept(noexcept(std::swap(a, b))) {
    std::swap(a, b);
}

If a noexcept function does throw, std::terminate() is called — no stack unwinding. This is intentional: a function that promised not to throw has a logic error, not a recoverable condition.

RAII Ensures Cleanup on Exceptions

Without RAII, every throw path requires manually releasing resources. With RAII, destructors run automatically during stack unwinding regardless of how the scope is exited — this is what makes RAII exception-safe by construction.

#include <fstream>
#include <mutex>

// Bad — manual cleanup; exception between open and close leaks the file
void bad_write(const std::string& path, const std::string& data) {
    FILE* f = fopen(path.c_str(), "w");
    process_data(data);  // if this throws, f is never closed — leak
    fwrite(data.data(), 1, data.size(), f);
    fclose(f);
}

// Good — RAII closes the file automatically even if an exception is thrown
void good_write(const std::string& path, const std::string& data) {
    std::ofstream f(path);       // opens file, destructor closes it
    process_data(data);          // exception? f's destructor still runs
    f.write(data.data(), data.size());
}

Error Codes with std::error_code

For system-level code or libraries that can’t use exceptions, std::error_code provides a type-safe, extensible error code system. It is the standard way to report filesystem, network, and OS errors in a portable, domain-aware way.

#include <system_error>
#include <filesystem>

std::error_code ec;
std::filesystem::create_directory("/tmp/myapp", ec);
if (ec) {
    std::cerr << "Failed: " << ec.message() << " (" << ec.value() << ")\n";
    // ec.category() tells you the error domain (generic, system, custom, etc.)
}

// Define custom error codes — integrates with the system_error framework
enum class AppError { ok = 0, invalid_input = 1, timeout = 2, not_found = 3 };

struct AppErrorCategory : std::error_category {
    const char* name() const noexcept override { return "app"; }
    std::string message(int ev) const override {
        switch (static_cast<AppError>(ev)) {
            case AppError::invalid_input: return "invalid input";
            case AppError::timeout:       return "operation timed out";
            case AppError::not_found:     return "resource not found";
            default:                      return "unknown error";
        }
    }
};

const AppErrorCategory& app_category() {
    static AppErrorCategory cat;
    return cat;
}

std::error_code make_error_code(AppError e) {
    return {static_cast<int>(e), app_category()};
}

std::expected (C++23) — Monadic Error Handling

std::expected<T, E> holds either a value of type T or an error of type E. It makes the error path explicit in the type system without the overhead of exceptions. The monadic interface (and_then, transform, or_else) lets you chain operations that might fail, short-circuiting on the first error without nested if-checks.

#include <expected>
#include <string>
#include <charconv>

std::expected<int, std::string> parse_int(std::string_view s) {
    int result = 0;
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), result);
    if (ec != std::errc{})
        return std::unexpected("not a valid integer: " + std::string(s));
    return result;
}

// Chain operations — if any step returns an error, the chain short-circuits
auto result = parse_int("42")
    .and_then([](int n) -> std::expected<int, std::string> {
        if (n < 0) return std::unexpected("must be non-negative");
        return n * 2;
    })
    .transform([](int n) { return std::to_string(n); });

if (result)
    std::cout << *result << "\n";     // "84"
else
    std::cout << result.error() << "\n";

Choosing a Strategy

SituationRecommended approach
Truly unexpected failure (I/O error, corrupt data)Exception
Expected failure on hot path (parsing, lookup)std::expected or error code
OS/system interfacestd::error_code
Precondition violation (programming bug)assert in debug, [[unlikely]] path in release
Destructor, move constructor, swapnoexcept — never throw

Exceptions shine when failures are rare and propagation would require threading error codes through many layers. Error codes shine when failures are frequent, expected, and the call site is right next to the decision point.

Frequently Asked Questions

Should I use exceptions or error codes?
Use exceptions for truly exceptional situations. Use error codes (or std::expected) for expected failures in performance-critical paths. Exceptions have zero cost when not thrown but incur overhead when thrown.
What does noexcept do to performance?
It allows the compiler to skip stack unwinding setup and enables optimizations (e.g., std::vector uses move constructors only if they're noexcept).