Master Exception Handling to Write Robust, Error-Resilient C++
Learn to throw, catch, and design exception hierarchies so your C++ programs handle errors safely without leaking resources.
By the end of this page, you will know how to throw and catch exceptions, build a custom exception hierarchy, use noexcept correctly, and avoid the three most common mistakes that turn exception handling into a source of bugs rather than a fix for them.
What and Why
Imagine a function that opens a file. It can fail β the file might not exist, permissions might be denied, the disk might be full. One approach is to return an error code:
int open_file(const char* path); // returns 0 on success, -1 on failureThis works, but callers can silently ignore the return value, and threading error information through many layers of call stack means every intermediate function must check and forward the code β even if it has nothing useful to add.
Exceptions solve this differently: when something goes wrong, you throw an object describing the problem. That object automatically travels up the call stack until some caller catches it. Any frame that doesn't catch it just passes it upward β no boilerplate required. If nothing catches it, the program terminates with a diagnostic.
The key insight is that exceptions are for exceptional situations β events that the immediate caller cannot reasonably handle and that represent a deviation from the function's normal contract. Routine conditions (user typed bad input, a file wasn't found in a search path) are usually better served by return values or std::optional. Exceptions shine when something goes structurally wrong.
Step by Step
Throwing and catching
#include <stdexcept>
#include <iostream>
double divide(double a, double b) {
if (b == 0.0)
throw std::invalid_argument("division by zero");
return a / b;
}
int main() {
try {
std::cout << divide(10.0, 2.0) << '\n'; // prints 5
std::cout << divide(10.0, 0.0) << '\n'; // throws
} catch (const std::invalid_argument& e) {
std::cerr << "Error: " << e.what() << '\n';
}
}The try block wraps code that might throw. The catch block names the exception type it handles. Execution jumps directly from the throw to the matching catch β everything between is unwound.
Catch by const reference, not by value. Catching by value copies the exception object and slices derived types down to the base.
The standard exception hierarchy
The standard library provides a ready-made hierarchy rooted at std::exception:
std::exception
βββ std::logic_error (violated preconditions, programmer errors)
β βββ std::invalid_argument
β βββ std::out_of_range
β βββ std::length_error
βββ std::runtime_error (external conditions the program can't control)
βββ std::overflow_error
βββ std::underflow_error
βββ std::system_error (OS/IO errors, carries an error_code)You can catch at any level:
try {
risky_operation();
} catch (const std::logic_error& e) {
// programmer bug β log and abort
} catch (const std::runtime_error& e) {
// environmental failure β retry or degrade gracefully
} catch (const std::exception& e) {
// catch-all for any standard exception
} catch (...) {
// catch absolutely everything, including non-standard throws
}catch clauses are tested top to bottom. Put more specific types first.
Custom exception types
#include <stdexcept>
#include <string>
class DatabaseError : public std::runtime_error {
public:
explicit DatabaseError(const std::string& msg, int code)
: std::runtime_error(msg), error_code_(code) {}
int error_code() const noexcept { return error_code_; }
private:
int error_code_;
};
void connect(const std::string& dsn) {
throw DatabaseError("connection refused", 1049);
}
int main() {
try {
connect("mysql://localhost/mydb");
} catch (const DatabaseError& e) {
// catches DatabaseError specifically
std::cerr << "[" << e.error_code() << "] " << e.what() << '\n';
} catch (const std::runtime_error& e) {
// would also catch DatabaseError if the above weren't there
std::cerr << "Runtime error: " << e.what() << '\n';
}
}Inheriting from std::runtime_error or std::logic_error means callers who only know about the base types can still catch your exceptions β crucial for library boundaries.
noexcept β promising you won't throw
Mark a function noexcept when it genuinely cannot throw:
int add(int a, int b) noexcept { return a + b; }If a noexcept function does throw (perhaps via a called function), std::terminate is called immediately. That might sound harsh, but it's the right tradeoff: move constructors and swap functions should be noexcept so the standard library can use them in strong-exception-safe algorithms. A throwing move constructor forces std::vector to copy instead of move during reallocation.
class Buffer {
public:
Buffer(Buffer&& other) noexcept; // lets vector move efficiently
Buffer& operator=(Buffer&& other) noexcept;
~Buffer() noexcept; // destructors are implicitly noexcept
};Common Patterns
RAII makes exceptions safe automatically
The single most important technique for exception safety is RAII (Resource Acquisition Is Initialization). When an exception unwinds the stack, all local objects with automatic storage duration are destroyed β their destructors run. If resources are tied to object lifetime, they cannot leak.
#include <fstream>
#include <string>
std::string read_file(const std::string& path) {
std::ifstream file(path); // opens on construction
if (!file.is_open())
throw std::runtime_error("cannot open: " + path);
std::string content((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
return content;
// file closes here β even if an exception was thrown above
}No try/finally needed. The destructor handles cleanup unconditionally.
Re-throwing inside a catch block
Sometimes you want to log and then let the exception continue propagating:
void process() {
try {
load_data();
} catch (const std::exception& e) {
log_error(e.what());
throw; // re-throw the *original* exception, not a copy
}
}Use bare throw; β not throw e;. The latter creates a new copy of e as a std::exception, losing any derived-type information.
Translating exceptions at layer boundaries
At the boundary between a library and application code, translate low-level exceptions into domain-specific ones:
UserRecord load_user(int id) {
try {
return db_.query("SELECT * FROM users WHERE id = ?", id);
} catch (const DatabaseError& e) {
throw UserNotFoundError(id, e.what());
}
}This keeps callers insulated from implementation details and keeps exception hierarchies meaningful at each abstraction level.
Transporting exceptions across threads with std::exception_ptr (C++11)
Exceptions cannot cross thread boundaries on their own β if a worker thread throws and nothing catches it, std::terminate is called. std::exception_ptr (C++11) solves this: capture the active exception on the worker thread, transfer the pointer to the main thread, and re-throw there.
#include <exception>
#include <stdexcept>
#include <thread>
#include <iostream>
std::exception_ptr worker_exception = nullptr;
void worker() {
try {
throw std::runtime_error("worker failed");
} catch (...) {
worker_exception = std::current_exception(); // C++11: capture
}
}
int main() {
std::thread t(worker);
t.join();
if (worker_exception) {
try {
std::rethrow_exception(worker_exception); // C++11: re-throw on main thread
} catch (const std::exception& e) {
std::cerr << "Caught from worker: " << e.what() << '\n';
}
}
}std::exception_ptr is a shared-ownership pointer: the exception object lives as long as at least one exception_ptr refers to it. This is also how std::future propagates exceptions from std::async tasks back to the caller via future::get().
What Can Go Wrong
Catching by value (slicing)
// Wrong
catch (std::exception e) { ... }
// Right
catch (const std::exception& e) { ... }Catching by value copies the exception and slices off any derived members. e.what() may return a meaningless base-class message.
Throwing from a destructor
class Bad {
public:
~Bad() {
throw std::runtime_error("oops"); // undefined behavior if already unwinding
}
};If a destructor throws while the stack is already being unwound due to another exception, std::terminate is called. Destructors must be noexcept. Handle errors in destructors by catching internally and logging, not propagating.
Swallowing exceptions silently
// Wrong β hides bugs
try {
do_something();
} catch (...) {}
// Right β at minimum, log before swallowing
try {
do_something();
} catch (const std::exception& e) {
log_error(e.what());
} catch (...) {
log_error("unknown exception");
}An empty catch block that discards every exception makes bugs impossible to diagnose. If you genuinely need to swallow, at minimum log the exception type and message.
Quick Reference
| Keyword / Tool | Purpose |
|---|---|
throw expr | Throw an exception; begins stack unwinding |
try { } catch (T& e) { } | Handle exceptions of type T or its bases |
throw; | Re-throw the current exception (inside a catch) |
catch (...) | Catch anything, including non-std::exception types |
noexcept | Promise a function won't throw; enables optimizations |
e.what() | Retrieve the message from any std::exception |
std::exception_ptr | Store and transport an exception across threads |
std::current_exception() | Capture the active exception as exception_ptr |
std::rethrow_exception(p) | Re-throw a captured exception_ptr |
Exception safety levels (strongest to weakest):
- No-throw β guaranteed not to throw (
noexcept) - Strong β operation succeeds or leaves state unchanged
- Basic β state remains valid but unspecified on failure
- No guarantee β anything could happen (avoid)
Exception Hierarchies: Designing a Custom Tree
A well-designed exception hierarchy mirrors your application's domain, not the standard library's categories. Start with a project-root base class that catches every application error in a single handler, then branch into subsystems. Each derived type adds context-specific fields while inheriting what() from std::runtime_error.
#include <stdexcept>
#include <string>
// Root for all application exceptions
class AppError : public std::runtime_error {
public:
explicit AppError(const std::string& msg)
: std::runtime_error(msg) {}
};
// Subsystem: network
class NetworkError : public AppError {
public:
NetworkError(const std::string& msg, int http_code)
: AppError(msg), http_code_(http_code) {}
int http_code() const noexcept { return http_code_; }
private:
int http_code_;
};
// Subsystem: network, more specific
class TimeoutError : public NetworkError {
public:
TimeoutError(const std::string& host, int ms)
: NetworkError("connection timed out to " + host, 408),
host_(host), timeout_ms_(ms) {}
const std::string& host() const noexcept { return host_; }
int timeout_ms() const noexcept { return timeout_ms_; }
private:
std::string host_;
int timeout_ms_;
};
void fetch(const std::string& url) {
throw TimeoutError("api.example.com", 5000);
}
int main() {
try {
fetch("https://api.example.com/data");
} catch (const TimeoutError& e) {
// most specific handler wins
std::cerr << "Timeout after " << e.timeout_ms() << "ms\n";
} catch (const NetworkError& e) {
std::cerr << "HTTP " << e.http_code() << ": " << e.what() << '\n';
} catch (const AppError& e) {
std::cerr << "App error: " << e.what() << '\n';
}
}Keep the hierarchy shallow β three levels is usually enough. Deep hierarchies become hard to navigate and tempt callers to catch at the root for convenience, defeating the purpose.
std::current_exception and std::rethrow_exception
std::current_exception() freezes the currently active exception into a portable handle that can be inspected or re-thrown at any later point. It returns a std::exception_ptr β a nullable shared pointer to the exception object. Calling it outside of an active exception (i.e., not inside a catch block) returns a null exception_ptr.
#include <exception>
#include <stdexcept>
#include <iostream>
std::exception_ptr capture_exception() {
try {
throw std::runtime_error("something went wrong");
} catch (...) {
return std::current_exception(); // freeze the active exception
}
}
void replay(std::exception_ptr ep) {
if (!ep) return;
try {
std::rethrow_exception(ep); // re-throw wherever you need
} catch (const std::exception& e) {
std::cerr << "Replayed: " << e.what() << '\n';
}
}
int main() {
auto ep = capture_exception();
replay(ep); // prints: Replayed: something went wrong
replay(ep); // can be replayed multiple times; the object is reference-counted
}std::rethrow_exception re-throws the original exception object β the full derived type is preserved, not just the base-class view. The exception_ptr keeps the exception alive until all copies of the pointer are destroyed.
std::exception_ptr in Multithreading
Exceptions thrown on a worker thread cannot propagate across thread boundaries on their own β the runtime calls std::terminate if a thread exits with an unhandled exception. std::exception_ptr bridges this gap: capture the exception on the worker, transfer the pointer, and re-throw on the calling thread.
#include <exception>
#include <stdexcept>
#include <thread>
#include <vector>
#include <iostream>
std::exception_ptr g_exc;
void worker() {
try {
throw std::runtime_error("worker thread failed");
} catch (...) {
g_exc = std::current_exception();
}
}
int main() {
std::thread t(worker);
t.join();
if (g_exc) {
try {
std::rethrow_exception(g_exc);
} catch (const std::exception& e) {
std::cerr << "Main caught: " << e.what() << '\n';
}
}
}std::future (from <future>) automates this pattern. When a task launched with std::async throws, the exception is stored inside the future and re-thrown when you call future::get():
#include <future>
#include <stdexcept>
#include <iostream>
int risky_compute() {
throw std::runtime_error("compute failed");
}
int main() {
auto f = std::async(std::launch::async, risky_compute);
try {
int result = f.get(); // re-throws if the task threw
} catch (const std::exception& e) {
std::cerr << "Async task threw: " << e.what() << '\n';
}
}For multiple worker threads, use a std::vector<std::exception_ptr> β one slot per thread β and check each after all threads have joined.
noexcept Specifier and Conditional noexcept
noexcept is a contract with the compiler and the standard library: this function will not let an exception escape. Marking move constructors and swap functions noexcept is the most performance-critical use: std::vector will move elements during reallocation only if the move constructor is noexcept. Otherwise it falls back to copying.
class Buffer {
char* data_;
std::size_t size_;
public:
// noexcept move constructor β allows std::vector to move, not copy
Buffer(Buffer&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
}
return *this;
}
~Buffer() noexcept { delete[] data_; }
};Conditional noexcept propagates the exception guarantee of a nested operation:
template <typename T>
void swap_pair(T& a, T& b) noexcept(noexcept(std::swap(a, b))) {
std::swap(a, b);
}noexcept(expr) evaluates expr at compile time β if std::swap(a, b) is noexcept, the enclosing function is too. This is the pattern the standard library uses in its own swap and move implementations. Query whether a function is noexcept at compile time with noexcept(f(args...)) as a boolean expression.
Stack Unwinding and Destructor Calls
When an exception is thrown, the C++ runtime unwinds the stack frame by frame, destroying every local variable whose constructor completed before the throw. This is the mechanism that makes RAII work: you do not need finally blocks because the destructors always run.
#include <iostream>
#include <stdexcept>
struct Guard {
std::string name;
Guard(std::string n) : name(std::move(n)) {
std::cout << name << " acquired\n";
}
~Guard() {
std::cout << name << " released\n"; // always runs, even during unwinding
}
};
void inner() {
Guard g2("inner-lock");
throw std::runtime_error("something broke");
}
void outer() {
Guard g1("outer-lock");
inner(); // throws
}
int main() {
try {
outer();
} catch (const std::exception& e) {
std::cerr << "Caught: " << e.what() << '\n';
}
// Output:
// outer-lock acquired
// inner-lock acquired
// inner-lock released <- destructor ran during unwinding
// outer-lock released <- destructor ran during unwinding
// Caught: something broke
}The destructor call order is the reverse of construction order β innermost scope first. The only time destructors do not run during unwinding is when a second exception is thrown from a destructor while the first is active, which calls std::terminate. Keep destructors noexcept.
Exception-Free Code: std::expected, Error Codes, and When Not to Throw
Exceptions are not always the right tool. In performance-sensitive paths, embedded environments without runtime support, or interfaces where callers are expected to handle every error locally, alternatives are more appropriate.
Error codes (plain integers or std::error_code) are zero-overhead but must be checked at every call site. They are appropriate for C-compatible APIs and system-level code.
std::expected<T, E> (C++23) combines the best of both: it carries either a value or an error object, forces callers to handle both cases, and has no exception overhead:
#include <expected>
#include <string>
#include <fstream>
enum class FileError { not_found, permission_denied, io_error };
std::expected<std::string, FileError> read_file(const std::string& path) {
std::ifstream f(path);
if (!f.is_open())
return std::unexpected(FileError::not_found);
std::string content((std::istreambuf_iterator<char>(f)),
std::istreambuf_iterator<char>());
return content;
}
int main() {
auto result = read_file("config.txt");
if (result) {
std::cout << "Content: " << *result << '\n';
} else {
switch (result.error()) {
case FileError::not_found:
std::cerr << "File not found\n"; break;
case FileError::permission_denied:
std::cerr << "Permission denied\n"; break;
default:
std::cerr << "I/O error\n"; break;
}
}
}Use exceptions for conditions that are genuinely exceptional β unexpected failures that most call sites cannot handle locally. Use std::expected or error codes for expected failure modes that are part of the normal operation contract.
Catch by What? const&, Base Classes, and Catch-All
The catch clause type determines which exception objects match, following the same rules as reference binding: a const Base& handler catches any thrown object of type Base or any class derived from it. A few rules govern the match order and how you should write handlers:
#include <stdexcept>
#include <iostream>
void demo() {
throw std::out_of_range("index 42 is out of bounds");
}
int main() {
try {
demo();
// Most-derived first: catches std::out_of_range exactly
} catch (const std::out_of_range& e) {
std::cerr << "Out of range: " << e.what() << '\n';
// Catches std::logic_error and all its children
} catch (const std::logic_error& e) {
std::cerr << "Logic: " << e.what() << '\n';
// Catches any std::exception
} catch (const std::exception& e) {
std::cerr << "Exception: " << e.what() << '\n';
// Catch-all: handles anything, including int, const char*, etc.
} catch (...) {
std::cerr << "Unknown exception\n";
}
}Rules to remember:
- Always catch by
const T&β never by value (avoids slicing) and never by non-const reference (exceptions are temporary objects). catch (...)matches everything, including C++ exceptions that do not derive fromstd::exception. Place it last.- Catch clauses are tried in order β the first match wins. A base-class handler before a derived-class handler makes the derived handler unreachable.
Testing Exception Behavior with Google Test
Google Test provides EXPECT_THROW, ASSERT_THROW, EXPECT_NO_THROW, and EXPECT_ANY_THROW to verify that functions throw the right exception type with the right message. These macros integrate cleanly with the rest of a unit test suite and report failures at the assertion level, not via an uncaught exception crashing the test binary.
#include <gtest/gtest.h>
#include <stdexcept>
double divide(double a, double b) {
if (b == 0.0)
throw std::invalid_argument("division by zero");
return a / b;
}
TEST(DivideTest, ThrowsOnZeroDenominator) {
// Verify that the right exception type is thrown
EXPECT_THROW(divide(1.0, 0.0), std::invalid_argument);
}
TEST(DivideTest, ExceptionCarriesCorrectMessage) {
// Catch the exception manually to inspect its message
try {
divide(1.0, 0.0);
FAIL() << "Expected std::invalid_argument";
} catch (const std::invalid_argument& e) {
EXPECT_STREQ(e.what(), "division by zero");
}
}
TEST(DivideTest, DoesNotThrowForValidInput) {
EXPECT_NO_THROW(divide(10.0, 2.0));
}
TEST(DivideTest, ResultIsCorrect) {
EXPECT_DOUBLE_EQ(divide(10.0, 2.0), 5.0);
}For custom exception types, substitute the exact class in EXPECT_THROW. If you need to verify fields beyond what(), the manual try/catch pattern in ExceptionCarriesCorrectMessage above gives you full access to the exception object before the test framework resumes.
What's Next
- Exception Safety Guarantees β deep dive into the four levels and how to achieve them with RAII and copy-and-swap
- Error Handling Strategies β when to use exceptions vs
std::expectedvs error codes - RAII and Resource Management β the technique that makes exception safety practical
- Move Semantics β why
noexceptmove constructors matter for standard containers