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
- Throw by value, catch by const reference — avoids slicing and unnecessary copies
- Create exception hierarchies — allow handlers to catch by granularity (specific type first, then base)
- Use
noexceptfor functions that cannot throw — enables compiler optimizations and clarifies contracts - Never throw from destructors — mark destructors
noexcept(true)by default in C++11 and later - Use RAII for resource management — ensures cleanup even when exceptions occur
- Consider the performance cost — exception handling has zero cost on the happy path (with modern implementations) but significant cost when actually thrown
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
- Use
std::error_codefor portable, extensible error reporting — it integrates with<system_error>and standard categories - Create custom error categories for domain-specific errors — use the singleton pattern for category instances
- Never ignore returned error codes — cast to
voidexplicitly if intentional, or use[[nodiscard]]on the return type - Avoid raw integer error codes — they lack context, are ambiguous, and cannot carry messages
- Document all possible error codes a function can return — callers need to know what to handle
- Consider wrapping return values in
std::optionalor a result type — avoids the awkward output parameter pattern
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
- Use
std::optionalfor "no value," not for errors — it cannot carry error information - Prefer
value_or()for providing defaults — cleaner than ternary expressions - Use the
if (auto v = func(); v)pattern — limits scope of the optional variable - Be careful with
value()on empty optional — it throwsstd::bad_optional_access - In C++23, leverage monadic methods —
and_then,transform,or_elsefor clean chaining - Combine with error codes when you need to distinguish "empty" from "error" — see the Result pattern below
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
- Make error types descriptive — use enums,
std::error_code, or custom structs, never raw integers - Leverage monadic operations —
and_then,transform,or_else,map_errorproduce linear, readable error handling - Use
std::unexpectedto construct error paths — clearer than relying on implicit conversions - Combine with
std::error_codefor system errors — gives portable, meaningful error information - Consider a project-wide error type — a single
Errorenum or a variant-of-errors simplifies inter-function composition - Use
[[nodiscard]]on functions returning expected — prevents accidental ignoring of errors
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
- Always use RAII for resource management — never rely on manual cleanup calls
- Mark destructors
noexcept— throwing from destructors during stack unwinding is undefined behavior - Follow the Rule of Five — if you define a destructor, also define (or delete) copy/move constructors and assignment operators
- Prefer standard library RAII types —
std::unique_ptr,std::lock_guard,std::scoped_lockare well-tested - Use RAII for transactional semantics — commit in a dedicated method, rollback in the destructor
- Keep resource acquisition in constructors lightweight — avoid complex logic that itself might throw before acquisition is complete
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