← Back to DevBytes

Error Handling Patterns in C++

Introduction to Error Handling in C++

Error handling is the structured approach to detecting, reporting, and recovering from unexpected conditions that arise during program execution. In C++, error handling is not merely a defensive afterthought — it is a first-class design concern that shapes the architecture, reliability, and maintainability of your entire codebase. Unlike languages with managed runtimes and built-in exception propagation models, C++ gives you raw power and corresponding responsibility: you choose the error handling strategy that best fits your performance constraints, type safety requirements, and code clarity goals.

This tutorial covers the major error handling patterns available in modern C++, from classic exceptions and error codes to newer vocabulary types like std::optional and std::expected. Each pattern is accompanied by practical code examples, guidance on when to use it, and common pitfalls to avoid.

1. Exception-Based Error Handling

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

What It Is

Exception handling uses throw, try, and catch to separate normal control flow from error recovery logic. When an error occurs, an exception object is constructed and "thrown," unwinding the call stack until a matching catch handler is found. This pattern is the default error propagation mechanism in the C++ Standard Library and many third-party libraries.

Why It Matters

Exceptions decouple error detection from error handling. A function deep in the call stack can report a failure without needing to know how that failure will be handled, and higher-level code can centralize recovery logic. This leads to cleaner interfaces — functions return only their success values, not error codes cluttering the API. Exceptions also integrate seamlessly with RAII, ensuring resources are automatically cleaned up during stack unwinding.

How to Use It

Use exceptions for truly exceptional conditions — situations where the program cannot reasonably proceed with the current operation and where immediate local recovery is impossible. Always throw by value and catch by const reference. Never let exceptions escape from destructors, as throwing during stack unwinding typically calls std::terminate.

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

// Custom exception hierarchy
class FileError : public std::runtime_error {
public:
    explicit FileError(const std::string& msg) : std::runtime_error(msg) {}
};

class FileNotFound : public FileError {
public:
    explicit FileNotFound(const std::string& filename)
        : FileError("File not found: " + filename), filename_(filename) {}

    const std::string& filename() const { return filename_; }
private:
    std::string filename_;
};

class PermissionDenied : public FileError {
public:
    explicit PermissionDenied(const std::string& filename)
        : FileError("Permission denied: " + filename), filename_(filename) {}

    const std::string& filename() const { return filename_; }
private:
    std::string filename_;
};

// Function that may throw
std::string read_file_contents(const std::string& path) {
    std::ifstream file(path);
    if (!file.is_open()) {
        // Simulate checking error reason
        if (path.find("/restricted/") != std::string::npos) {
            throw PermissionDenied(path);
        }
        throw FileNotFound(path);
    }

    std::string content;
    std::string line;
    while (std::getline(file, line)) {
        content += line + "\n";
    }

    if (file.bad()) {
        throw FileError("I/O error while reading: " + path);
    }

    return content;
}

// Higher-level handler with structured recovery
void process_file(const std::string& path) {
    try {
        std::string content = read_file_contents(path);
        std::cout << "File read successfully. Content length: "
                  << content.size() << " bytes\n";
    }
    catch (const FileNotFound& e) {
        std::cerr << "Not found: " << e.filename() << "\n";
        // Recovery: create default file
        std::cerr << "Creating default configuration file...\n";
    }
    catch (const PermissionDenied& e) {
        std::cerr << "Access denied: " << e.filename() << "\n";
        // Recovery: request elevated privileges or skip
        std::cerr << "Skipping restricted file.\n";
    }
    catch (const FileError& e) {
        std::cerr << "Generic file error: " << e.what() << "\n";
        // Log and re-throw if unrecoverable
        throw;  // re-throws the current exception
    }
    catch (const std::exception& e) {
        std::cerr << "Unexpected error: " << e.what() << "\n";
        // Last resort: terminate or log fatal
    }
}

int main() {
    process_file("/home/user/data.txt");
    process_file("/restricted/admin.conf");
    return 0;
}

Best Practices for Exceptions

2. Error Code Pattern (Return Codes)

What It Is

The error code pattern uses function return values to indicate success or failure. Functions return a dedicated status code (often an integer, enum, or std::error_code) and pass actual results through output parameters or as part of a discriminated union. This is the oldest error handling pattern and remains prevalent in systems programming, real-time applications, and APIs that must be callable from C.

Why It Matters

Error codes are explicit, predictable, and have no hidden control flow. They avoid the overhead of stack unwinding machinery and are suitable for environments where exceptions are banned (embedded systems, some game engines, real-time loops). Error codes also map naturally to ABIs that must remain stable across language boundaries.

How to Use It

Modern C++ offers std::error_code and std::error_condition from <system_error> for portable, extensible error codes. Use enums or dedicated status types rather than raw integer magic values. Always check return values immediately and never discard them silently.

#include <iostream>
#include <system_error>
#include <string>
#include <fstream>
#include <optional>

// Custom error category for our application
class AppErrorCategory : public std::error_category {
public:
    const char* name() const noexcept override {
        return "application";
    }

    std::string message(int condition) const override {
        switch (static_cast<AppError>(condition)) {
            case AppError::ConfigParseError:
                return "Failed to parse configuration";
            case AppError::NetworkTimeout:
                return "Network operation timed out";
            case AppError::InvalidInput:
                return "Invalid input provided";
            default:
                return "Unknown application error";
        }
    }

    // Singleton pattern for error category
    static const AppErrorCategory& instance() {
        static AppErrorCategory cat;
        return cat;
    }
};

enum class AppError {
    NoError = 0,
    ConfigParseError = 1,
    NetworkTimeout = 2,
    InvalidInput = 3
};

// Make std::error_code from our custom enum
std::error_code make_app_error(AppError e) {
    return {static_cast<int>(e), AppErrorCategory::instance()};
}

// Function using std::error_code via output parameter
std::error_code load_config(const std::string& path,
                             std::string& out_content) {
    std::ifstream file(path);
    if (!file.is_open()) {
        return std::error_code(static_cast<int>(std::errc::no_such_file_or_directory),
                                std::generic_category());
    }

    std::string content;
    std::string line;
    while (std::getline(file, line)) {
        // Validate each line
        if (line.empty()) continue;
        if (line.front() == '#' || line.find('=') == std::string::npos) {
            out_content.clear();
            return make_app_error(AppError::ConfigParseError);
        }
        content += line + "\n";
    }

    out_content = content;
    return {};  // default-constructed error_code means success
}

// Usage example with explicit checking
int main() {
    std::string config_data;
    std::error_code ec = load_config("/etc/app/config.ini", config_data);

    if (ec) {
        std::cerr << "Error (" << ec.value() << "): "
                  << ec.message() << "\n";

        // Check specific error conditions
        if (ec == std::errc::no_such_file_or_directory) {
            std::cerr << "Configuration file missing, using defaults.\n";
            config_data = "default_key=default_value\n";
        }
        else if (ec == make_app_error(AppError::ConfigParseError)) {
            std::cerr << "Configuration malformed, aborting.\n";
            return 1;
        }
    }
    else {
        std::cout << "Configuration loaded successfully.\n";
    }

    std::cout << "Final config:\n" << config_data;
    return 0;
}

Best Practices for Error Codes

3. std::optional for Absence of Value

What It Is

std::optional<T> (introduced in C++17) represents a value that may or may not be present. It is not strictly an error handling mechanism — it signals the absence of a value rather than a failure — but it elegantly handles "not found" or "no result" scenarios without exceptions or sentinel values.

Why It Matters

Using std::optional eliminates null pointer dereferences, magic sentinel values (like -1 for "not found"), and the ambiguity between "empty result" and "error." It makes interfaces self-documenting: a function returning std::optional<int> clearly communicates that it might not produce a value. The type system enforces checking, reducing bugs.

How to Use It

Use std::optional for functions that may fail to produce a value in a non-exceptional way — like searching a collection, parsing user input, or looking up a cache entry. Combine it with error codes or exceptions when you need to distinguish between "no result" and "something went wrong."

#include <iostream>
#include <optional>
#include <string>
#include <vector>
#include <map>
#include <cmath>

// Function returning optional — "find or nothing"
std::optional<int> find_index(const std::vector<std::string>& items,
                                const std::string& target) {
    for (size_t i = 0; i < items.size(); ++i) {
        if (items[i] == target) {
            return static_cast<int>(i);  // wrap in optional
        }
    }
    return std::nullopt;  // explicit "no value"
}

// Parsing function — failure is expected and non-exceptional
std::optional<double> safe_sqrt(double value) {
    if (value < 0.0) {
        return std::nullopt;  // no real square root
    }
    return std::sqrt(value);
}

// Combining optional with structured bindings
struct DatabaseLookupResult {
    std::string name;
    int age;
    std::string email;
};

std::optional<DatabaseLookupResult> lookup_user(int user_id) {
    // Simulated database
    static std::map<int, DatabaseLookupResult> users = {
        {1, {"Alice", 30, "alice@example.com"}},
        {2, {"Bob", 25, "bob@example.com"}},
    };

    auto it = users.find(user_id);
    if (it != users.end()) {
        return it->second;
    }
    return std::nullopt;
}

int main() {
    std::vector<std::string> names = {"apple", "banana", "cherry"};

    // Usage pattern 1: value_or with default
    int index = find_index(names, "banana").value_or(-1);
    std::cout << "Index of banana: " << index << "\n";

    // Usage pattern 2: explicit check with has_value
    auto result = find_index(names, "dragonfruit");
    if (result.has_value()) {
        std::cout << "Found at position: " << *result << "\n";
    } else {
        std::cout << "Item not found in collection.\n";
    }

    // Usage pattern 3: optional with if-initializer (C++17)
    if (auto sqrt_result = safe_sqrt(-4.0); sqrt_result) {
        std::cout << "Square root: " << *sqrt_result << "\n";
    } else {
        std::cout << "Cannot compute square root of negative number.\n";
    }

    // Usage pattern 4: monadic operations (C++23)
    // In C++23, optional gains and_then, transform, or_else
    // For C++17/20, manual chaining:
    auto user = lookup_user(3);
    std::string email = user
        ? user->email
        : "no-user@unknown.com";
    std::cout << "Email: " << email << "\n";

    // Usage pattern 5: throwing on empty optional
    try {
        auto user2 = lookup_user(42).value();  // throws std::bad_optional_access
    } catch (const std::bad_optional_access& e) {
        std::cerr << "User not found: " << e.what() << "\n";
    }

    return 0;
}

Best Practices for std::optional

4. std::expected (Result Pattern) — C++23

What It Is

std::expected<T, E> is a discriminated union that holds either a value of type T (the expected success result) or an error of type E. It is the canonical "Result" type, popularized by Rust's Result<T, E> and long implemented in C++ as boost::outcome or custom types. Standardized in C++23, it bridges the gap between std::optional (no error information) and exceptions (implicit control flow).

Why It Matters

std::expected enables exhaustive error handling at compile time. The type system forces callers to acknowledge both success and failure paths, eliminating the class of bugs where error returns are accidentally ignored. It supports monadic operations (and_then, transform, or_else, map_error) that allow chaining operations without nested error checks. This pattern produces code that is both safe and readable, with zero overhead on the happy path.

How to Use It

Use std::expected as the primary return type for fallible functions in new C++23 codebases. It excels in domains where errors are common and must be handled explicitly — parsers, validators, network operations, file I/O, and business logic pipelines.

#include <expected>   // C++23
#include <iostream>
#include <string>
#include <vector>
#include <fstream>
#include <charconv>
#include <system_error>

// Define domain error types
enum class ParseError {
    EmptyInput,
    InvalidFormat,
    OutOfRange,
    UnexpectedCharacter
};

// Helper to convert ParseError to string for logging
std::string to_string(ParseError e) {
    switch (e) {
        case ParseError::EmptyInput: return "Empty input";
        case ParseError::InvalidFormat: return "Invalid format";
        case ParseError::OutOfRange: return "Value out of range";
        case ParseError::UnexpectedCharacter: return "Unexpected character";
        default: return "Unknown parse error";
    }
}

// Function returning std::expected
std::expected<int, ParseError> parse_integer(const std::string& input) {
    if (input.empty()) {
        return std::unexpected(ParseError::EmptyInput);
    }

    // Check for valid characters
    for (char c : input) {
        if (!std::isdigit(c) && c != '-' && c != '+') {
            return std::unexpected(ParseError::UnexpectedCharacter);
        }
    }

    int value = 0;
    auto result = std::from_chars(input.data(),
                                   input.data() + input.size(),
                                   value);

    if (result.ec == std::errc::invalid_argument) {
        return std::unexpected(ParseError::InvalidFormat);
    }
    if (result.ec == std::errc::result_out_of_range) {
        return std::unexpected(ParseError::OutOfRange);
    }

    return value;  // success
}

// Chaining with monadic operations
std::expected<std::vector<int>, ParseError>
parse_integer_list(const std::string& input) {
    if (input.empty()) {
        return std::unexpected(ParseError::EmptyInput);
    }

    std::vector<int> values;
    size_t pos = 0;

    while (pos < input.size()) {
        // Find next comma or end
        size_t comma = input.find(',', pos);
        std::string token = (comma == std::string::npos)
            ? input.substr(pos)
            : input.substr(pos, comma - pos);

        // Parse individual token — monadic chain
        auto parsed = parse_integer(token);
        if (!parsed) {
            return std::unexpected(parsed.error());
        }
        values.push_back(*parsed);

        if (comma == std::string::npos) break;
        pos = comma + 1;
    }

    return values;
}

// File reading with expected and system_error
std::expected<std::string, std::error_code>
read_text_file(const std::string& path) {
    std::ifstream file(path);
    if (!file.is_open()) {
        return std::unexpected(
            std::error_code(static_cast<int>(std::errc::no_such_file_or_directory),
                             std::generic_category())
        );
    }

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

    if (file.bad()) {
        return std::unexpected(
            std::error_code(static_cast<int>(std::errc::io_error),
                             std::generic_category())
        );
    }

    return content;
}

// Composing functions with and_then (C++23 monadic operations)
std::expected<std::vector<int>, ParseError>
load_and_parse(const std::string& filename) {
    // First read file — produces expected<string, error_code>
    auto content = read_text_file(filename);

    if (!content) {
        // Map filesystem error to parse context error
        std::cerr << "File error: " << content.error().message() << "\n";
        return std::unexpected(ParseError::EmptyInput);
    }

    // Parse the content
    return parse_integer_list(*content);
}

int main() {
    // Example 1: Direct pattern matching on expected
    auto result1 = parse_integer("42");
    if (result1) {
        std::cout << "Parsed value: " << *result1 << "\n";
    } else {
        std::cerr << "Parse error: " << to_string(result1.error()) << "\n";
    }

    // Example 2: Using value_or for defaults
    auto result2 = parse_integer("abc");
    int safe_value = result2.value_or(0);
    std::cout << "Safe value (with default): " << safe_value << "\n";

    // Example 3: Monadic transform (C++23)
    auto result3 = parse_integer("100");
    auto doubled = result3.transform([](int x) { return x * 2; });
    std::cout << "Doubled: " << doubled.value_or(-1) << "\n";

    // Example 4: and_then chaining
    auto result4 = parse_integer_list("10,20,30,40");
    if (result4) {
        std::cout << "Parsed " << result4->size() << " integers.\n";
        for (int v : *result4) {
            std::cout << v << " ";
        }
        std::cout << "\n";
    } else {
        std::cerr << "List parse failed: " << to_string(result4.error()) << "\n";
    }

    // Example 5: or_else for error recovery
    auto result5 = parse_integer("-999");
    auto clamped = result5.or_else([](ParseError e) -> std::expected<int, ParseError> {
        std::cerr << "Error encountered: " << to_string(e) << ", clamping to 0.\n";
        return 0;  // recover with default
    });
    std::cout << "Final value: " << *clamped << "\n";

    return 0;
}

Best Practices for std::expected

5. RAII as an Error Prevention Pattern

What It Is

Resource Acquisition Is Initialization (RAII) is not an error reporting pattern per se, but it is the fundamental C++ idiom that makes error handling safe. Resources (memory, file handles, locks, sockets) are acquired in constructors and released in destructors. When an error occurs and stack unwinding happens (whether via exception, early return, or error code propagation), destructors automatically clean up, preventing resource leaks.

Why It Matters

Without RAII, error handling requires manual cleanup at every exit point — a maintenance nightmare that inevitably leads to leaks. RAII guarantees that resources are released even in the presence of errors, exceptions, and complex control flow. It is the foundation upon which all other error handling patterns in C++ are built safely.

How to Use It

Use standard RAII types: std::unique_ptr, std::shared_ptr, std::lock_guard, std::scoped_lock, std::ifstream (which closes automatically). For custom resources, write classes that acquire in the constructor and release in the destructor, and mark destructors noexcept.

#include <iostream>
#include <mutex>
#include <memory>
#include <fstream>
#include <stdexcept>

// Custom RAII class for a hypothetical database connection
class DatabaseConnection {
public:
    explicit DatabaseConnection(const std::string& conn_string)
        : conn_string_(conn_string), connected_(false) {
        std::cout << "Opening connection to: " << conn_string_ << "\n";
        // Simulate connection establishment
        connected_ = true;
    }

    ~DatabaseConnection() noexcept {
        if (connected_) {
            std::cout << "Closing connection to: " << conn_string_ << "\n";
            // noexcept guarantee: cleanup must not throw
            connected_ = false;
        }
    }

    // Disable copying, enable moving
    DatabaseConnection(const DatabaseConnection&) = delete;
    DatabaseConnection& operator=(const DatabaseConnection&) = delete;

    DatabaseConnection(DatabaseConnection&& other) noexcept
        : conn_string_(std::move(other.conn_string_)),
          connected_(other.connected_) {
        other.connected_ = false;
    }

    DatabaseConnection& operator=(DatabaseConnection&& other) noexcept {
        if (this != &other) {
            if (connected_) {
                // cleanup current connection
                connected_ = false;
            }
            conn_string_ = std::move(other.conn_string_);
            connected_ = other.connected_;
            other.connected_ = false;
        }
        return *this;
    }

    void execute(const std::string& query) {
        if (!connected_) {
            throw std::runtime_error("Cannot execute on disconnected database");
        }
        std::cout << "Executing: " << query << "\n";
        // Simulate query...
    }

private:
    std::string conn_string_;
    bool connected_;
};

// RAII lock guard for thread safety
class ThreadSafeCounter {
public:
    void increment() {
        std::lock_guard<std::mutex> lock(mutex_);  // RAII: auto-unlock
        ++value_;
    }

    int get() const {
        std::lock_guard<std::mutex> lock(mutex_);
        return value_;
    }

private:
    mutable std::mutex mutex_;
    int value_ = 0;
};

// RAII file writer with transaction semantics
class TransactionalFileWriter {
public:
    explicit TransactionalFileWriter(const std::string& path)
        : path_(path), temp_path_(path + ".tmp"),
          file_(temp_path_, std::ios::out | std::ios::trunc) {
        if (!file_.is_open()) {
            throw std::runtime_error("Cannot open temp file: " + temp_path_);
        }
        std::cout << "Started transaction for: " << path_ << "\n";
    }

    ~TransactionalFileWriter() noexcept {
        if (file_.is_open()) {
            // If we reach destructor without commit, rollback
            file_.close();
            std::remove(temp_path_.c_str());
            std::cout << "Rolled back transaction (temp file deleted).\n";
        }
    }

    void write(const std::string& data) {
        file_ << data;
        if (file_.fail()) {
            throw std::runtime_error("Write failure during transaction");
        }
    }

    void commit() {
        file_.close();
        std::rename(temp_path_.c_str(), path_.c_str());
        std::cout << "Committed transaction to: " << path_ << "\n";
        // Prevent destructor rollback
    }

    // Disable copying
    TransactionalFileWriter(const TransactionalFileWriter&) = delete;
    TransactionalFileWriter& operator=(const TransactionalFileWriter&) = delete;

private:
    std::string path_;
    std::string temp_path_;
    std::ofstream file_;
};

// Demonstrating RAII safety under exceptions
void complex_operation() {
    // Acquire resources
    DatabaseConnection db("server:5432/mydb");
    ThreadSafeCounter counter;
    TransactionalFileWriter writer("output.txt");

    // Simulate work that may fail
    db.execute("SELECT * FROM users");

    // If this throws, all destructors clean up automatically
    writer.write("Important data\n");
    writer.write("More data\n");

    // Simulate a failure
    throw std::runtime_error("Mid-operation failure!");

    // This line never reached, but cleanup still happens
    writer.commit();
}

int main() {
    try {
        complex_operation();
    }
    catch (const std::exception& e) {
        std::cerr << "Caught exception: " << e.what() << "\n";
        // All resources have already been cleaned up by RAII destructors
        std::cerr << "Resources safely released despite the error.\n";
    }

    std::cout << "Program continues safely after error recovery.\n";
    return 0;
}

Best Practices for RAII

6. Assertions and Contract Programming

What It Is

Assertions are runtime checks that validate program invariants during development and testing. They are typically enabled in debug builds and disabled in release builds. C++ offers assert() from <cassert> for traditional assertions, and libraries like Boost.Contract or compiler-specific intrinsics for more sophisticated contract programming. Assertions catch programmer errors — logic mistakes, invariant violations, and precondition failures — not recover

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles