An exception that nobody catches does not unwind the stack. The test program for this article threw from three functions deep with no handler anywhere, and neither GCC nor Clang ran a single destructor before the program aborted. Wrap the same call in a try block and all three destructors run in reverse order. The difference between those two runs is the most useful thing to understand about exceptions in C++, because every other rule, from RAII to noexcept, is built on what happens to objects while an exception is in flight.
This guide covers the basics: throw, try and catch, stack unwinding, and catching correctly. It then covers the standard exception hierarchy and the parts that decide whether exception-safe code actually is: RAII, the three exception-safety guarantees, and what noexcept changes. It closes with what a thrown exception costs compared with C++23’s std::expected, and the old features you should no longer write. Every program was compiled and run for this article on Ubuntu 24.04 with g++ 13.3 and clang++ 18.1.3 under -std=c++17 and -std=c++20 with -Wall -Wextra -pedantic; the std::expected programs need C++23 and were built with g++ only, for a reason explained in that section. The working examples produce no warnings and run clean under AddressSanitizer and UndefinedBehaviorSanitizer; the deliberately broken ones are shown with the diagnostics they actually produced. All output is captured verbatim.
What Is Exception Handling in C++?
Exception handling in C++ is the mechanism for reporting an error from the point where it is detected to a point that can deal with it, without passing error codes back through every function in between. Code signals the error with throw, the code that might fail runs inside a try block, and a catch handler that matches the thrown type receives it. On the way, every fully constructed local object in the functions being left is destroyed.
| Keyword | What it does |
|---|---|
throw | Creates an exception object and starts the search for a handler |
try | Marks a block whose exceptions the following handlers will consider |
catch | Declares a handler for one type (or ... for any type) |
noexcept | Declares that a function will not let an exception escape |
The rules are summarized on cppreference’s exceptions page, and the C++ Core Guidelines give error handling a section of its own.
throw, try and catch
The standard library already throws, so the simplest useful example wraps a library call. std::stoi throws std::invalid_argument for text that is not a number and std::out_of_range for a number too large for an int. The function adds a range check of its own:
// basics.cpp - throw, try and catch around a real library call
#include <iostream>
#include <stdexcept>
#include <string>
int parse_port(const std::string& text)
{
int value = std::stoi(text); // may throw invalid_argument or out_of_range
if (value < 1 || value > 65535)
throw std::out_of_range("port " + text + " is outside 1-65535");
return value;
}
int main()
{
for (const std::string input : {"8080", "http", "70000", "99999999999"}) {
try {
int port = parse_port(input);
std::cout << input << " -> port " << port << '\n';
} catch (const std::invalid_argument& e) {
std::cout << input << " -> not a number (" << e.what() << ")\n";
} catch (const std::out_of_range& e) {
std::cout << input << " -> out of range (" << e.what() << ")\n";
}
}
}
Output:
8080 -> port 8080
http -> not a number (stoi)
70000 -> out of range (port 70000 is outside 1-65535)
99999999999 -> out of range (stoi)
Each failure left parse_port at the throw (or inside std::stoi), skipped the rest of the function, and landed in the first handler whose type matched. Handlers are tried in order, and the first match wins. Nothing after the failing call in the try block ran, and the loop carried on with the next input.
Note what what() returned for the two library exceptions: just stoi. The text of a standard exception’s message is implementation-defined, so it is fine for a log but not something to show a user or test against. When the message matters, throw your own exception with your own text, as the range check does.
Stack Unwinding: What Gets Cleaned Up
When an exception leaves a function, the objects that function had fully constructed are destroyed in reverse order of construction. This is called stack unwinding, and it continues function by function until a handler is found:
// unwind.cpp - which destructors run when an exception leaves a scope
#include <iostream>
#include <stdexcept>
struct Tracer {
const char* name;
explicit Tracer(const char* n) : name(n) { std::cout << " make " << name << '\n'; }
~Tracer() { std::cout << " drop " << name << '\n'; }
};
void load()
{
Tracer file("file");
Tracer buffer("buffer");
throw std::runtime_error("disk read failed");
Tracer never("never"); // not reached
}
void run()
{
Tracer session("session");
load();
}
int main()
{
try {
run();
} catch (const std::exception& e) {
std::cout << "caught: " << e.what() << '\n';
}
}
Output:
make session
make file
make buffer
drop buffer
drop file
drop session
caught: disk read failed
buffer, file and session were all destroyed before the handler ran, newest first, and never was never constructed, so it was never destroyed either.
When Nobody Catches the Exception
Here is the same program with the try block removed:
// uncaught.cpp - the same program with nobody catching
#include <iostream>
#include <stdexcept>
struct Tracer {
const char* name;
explicit Tracer(const char* n) : name(n) { std::cout << " make " << name << std::endl; }
~Tracer() { std::cout << " drop " << name << std::endl; }
};
void load()
{
Tracer file("file");
Tracer buffer("buffer");
throw std::runtime_error("disk read failed");
}
void run()
{
Tracer session("session");
load();
}
int main()
{
run(); // no try/catch anywhere
}
Output (g++ and clang++, at both -O0 and -O2; exit status 134, SIGABRT):
make session
make file
make buffer
terminate called after throwing an instance of 'std::runtime_error'
what(): disk read failed
No drop line appeared. When no handler exists, the standard calls std::terminate, and it leaves implementation-defined whether the stack is unwound first. With GCC and Clang on Linux, it was not: the runtime looks for a handler before unwinding anything, finds none, and aborts with every object still alive. Destructors that flush a file, release a lock or roll back a transaction simply do not run. This version uses std::endl so that each line reaches the terminal before the abort; with '\n' and output redirected to a file, the buffered make lines are lost as well.
The practical rule: if cleanup matters, make sure something catches. A try block in main with a final catch (...) is enough to guarantee unwinding.
Logging Before the Program Ends
std::set_terminate installs a function that runs when std::terminate is called. It cannot recover the program, but it can record why it died:
// term.cpp - log the reason before an uncaught exception ends the program
#include <cstdlib>
#include <exception>
#include <iostream>
#include <stdexcept>
[[noreturn]] void log_and_abort()
{
std::cerr << "fatal: ";
if (auto p = std::current_exception()) {
try { std::rethrow_exception(p); }
catch (const std::exception& e) { std::cerr << e.what(); }
catch (...) { std::cerr << "non-standard exception"; }
}
std::cerr << std::endl;
std::abort(); // a terminate handler must not return
}
int main()
{
std::set_terminate(log_and_abort);
throw std::runtime_error("config file missing");
}
Output (exit status 134):
fatal: config file missing
Inside the handler, std::current_exception returns the exception that caused termination, and rethrowing it into a local try block recovers its type and message. The handler must not return, which is why it ends in std::abort and is marked [[noreturn]].
Catching Correctly: By Reference, Most Specific First
Two rules prevent most catching mistakes, and this program breaks both:
// slicing.cpp - catching by value, and rethrowing with throw e;
#include <iostream>
#include <stdexcept>
void fetch() { throw std::out_of_range("index 12 >= size 10"); }
int main()
{
try { fetch(); }
catch (std::exception e) { // by value: sliced
std::cout << "by value: " << e.what() << '\n';
}
try { fetch(); }
catch (const std::exception& e) { // by reference
std::cout << "by reference: " << e.what() << '\n';
}
for (int variant = 0; variant < 2; ++variant) {
try {
try { fetch(); }
catch (std::exception& e) {
if (variant == 0) throw; // rethrows the original object
else throw e; // throws a new std::exception copy
}
} catch (const std::out_of_range&) {
std::cout << (variant == 0 ? "throw; " : "throw e; ") << "-> still out_of_range\n";
} catch (const std::exception& e) {
std::cout << (variant == 0 ? "throw; " : "throw e; ") << "-> now a plain std::exception: " << e.what() << '\n';
}
}
}
Output:
by value: std::exception
by reference: index 12 >= size 10
throw; -> still out_of_range
throw e; -> now a plain std::exception: std::exception
Catch by reference. catch (std::exception e) copies only the std::exception part of the thrown std::out_of_range, a problem called slicing. The copy is a plain std::exception, and its what() returned the generic std::exception instead of the real message. g++ warns about this under -Wall (catching polymorphic type 'class std::exception' by value [-Wcatch-value=]); clang++ 18 said nothing. Write catch (const std::exception& e).
Rethrow with throw;. Inside a handler, throw; rethrows the original exception object, type and all. throw e; throws a new object of e‘s static type, here std::exception. The outer catch (const std::out_of_range&) then missed it. Neither compiler warned about throw e;.
Order handlers from most to least specific. A handler for a base class catches every derived type, so a derived handler placed after it can never run:
try {
throw std::out_of_range("index 12 >= size 10");
} catch (const std::exception& e) {
std::cout << "generic handler: " << e.what() << '\n';
} catch (const std::out_of_range& e) { // never reached
std::cout << "specific handler: " << e.what() << '\n';
}
Both compilers flagged this one, and it printed generic handler: index 12 >= size 10:
warning: exception of type 'std::out_of_range' will be caught by earlier handler [-Wexceptions] (g++)
warning: exception of type 'const std::out_of_range &' will be caught by earlier handler [-Wexceptions] (clang++)
The std::exception Hierarchy
Every exception the standard library throws derives from std::exception, and the hierarchy is what makes catching by base class useful:
What throws the types you are most likely to meet (each one was triggered and caught in a test program for this article):
| Exception | Thrown by, for example |
|---|---|
std::invalid_argument | std::stoi("abc") |
std::out_of_range | std::vector::at past the end; std::stoi with a number too large |
std::length_error | std::vector::reserve beyond max_size() |
std::bad_alloc | new when memory cannot be allocated |
std::bad_cast | dynamic_cast to a reference type that does not match |
std::bad_typeid | typeid(*p) where p is a null pointer to a polymorphic type |
std::bad_function_call | Calling an empty std::function |
std::bad_optional_access | value() on an empty std::optional |
std::bad_variant_access | std::get with the wrong alternative |
std::bad_expected_access | value() on a std::expected that holds an error (C++23) |
Two groupings are easy to get wrong. std::bad_alloc is not a runtime_error, so a handler for std::runtime_error does not catch out-of-memory; neither is it a logic_error. And range_error is a runtime_error, while out_of_range is a logic_error, despite the similar names.
Application exceptions should join the hierarchy rather than start a new one, usually by deriving from std::runtime_error, which stores the message for you:
// custom.cpp - an application exception that fits the standard hierarchy
#include <iostream>
#include <stdexcept>
#include <string>
class ConfigError : public std::runtime_error {
public:
ConfigError(const std::string& file, int line)
: std::runtime_error(file + ":" + std::to_string(line) + ": invalid value"),
line_(line) {}
int line() const noexcept { return line_; }
private:
int line_;
};
int main()
{
try {
throw ConfigError("app.conf", 14);
} catch (const ConfigError& e) { // code that knows the type gets the details
std::cout << "line " << e.line() << ": " << e.what() << '\n';
}
try {
throw ConfigError("app.conf", 14);
} catch (const std::exception& e) { // generic code still gets a message
std::cout << "generic: " << e.what() << '\n';
}
}
Output:
line 14: app.conf:14: invalid value
generic: app.conf:14: invalid value
Code that knows about ConfigError can use the extra data; code that only knows about std::exception still gets a meaningful message. Throwing an int or a string literal, both legal, gives neither.
RAII: Why Constructors Leak and How Not To
A destructor runs only for an object whose constructor finished. If a constructor throws halfway through, anything it had already acquired by hand is never released:
// leak.cpp - a constructor that throws after one raw allocation
#include <cstddef>
#include <iostream>
#include <stdexcept>
class Image {
public:
Image(std::size_t w, std::size_t h)
: pixels_(new unsigned char[w * h]), // 1. first allocation succeeds
palette_(make_palette(w)) // 2. this throws
{}
~Image() { delete[] palette_; delete[] pixels_; } // never runs if the constructor throws
private:
static unsigned char* make_palette(std::size_t w)
{
if (w > 4096) throw std::length_error("image too wide");
return new unsigned char[256 * 3];
}
unsigned char* pixels_;
unsigned char* palette_;
};
int main()
{
try {
Image img(5000, 100);
} catch (const std::exception& e) {
std::cout << "caught: " << e.what() << '\n';
}
}
Output:
caught: image too wide
The program reported success in catching the error, compiled without a warning, and exited normally. LeakSanitizer (-fsanitize=address) showed what was left behind:
ERROR: LeakSanitizer: detected memory leaks
Direct leak of 500000 byte(s) in 1 object(s) allocated from:
#1 0x5575cd1e8650 in Image::Image(unsigned long, unsigned long) leak.cpp:9
SUMMARY: AddressSanitizer: 500000 byte(s) leaked in 1 allocation(s).
pixels_ was allocated, make_palette threw, and because Image was never fully constructed, ~Image never ran. The fix is not a try block in the constructor. It is to make every member responsible for its own resource, so that the language’s own rule, destroy every fully constructed subobject, does the cleanup:
// noleak.cpp - the same class with members that own their memory
#include <cstddef>
#include <iostream>
#include <stdexcept>
#include <vector>
class Image {
public:
Image(std::size_t w, std::size_t h)
: pixels_(w * h), // std::vector owns this buffer
palette_(make_palette(w))
{}
// no destructor needed
private:
static std::vector<unsigned char> make_palette(std::size_t w)
{
if (w > 4096) throw std::length_error("image too wide");
return std::vector<unsigned char>(256 * 3);
}
std::vector<unsigned char> pixels_;
std::vector<unsigned char> palette_;
};
int main()
{
try {
Image img(5000, 100);
} catch (const std::exception& e) {
std::cout << "caught: " << e.what() << '\n';
}
}
Output:
caught: image too wide
With pixels_ as a std::vector, it was a fully constructed member when make_palette threw, so its destructor ran during unwinding. LeakSanitizer reported nothing, with both compilers. This pattern, tying a resource to an object’s lifetime, is called RAII (Resource Acquisition Is Initialization). std::vector, std::string, std::unique_ptr, std::lock_guard and std::fstream are all RAII types, and a class built from them needs no destructor of its own. Our guide to classes in C++ covers constructors and destructors in more detail.
Exception-Safety Guarantees
Unwinding cleans up objects; it does not undo changes to objects that survive. What a function promises about the state it leaves behind when it throws is described by three levels:
| Guarantee | If an exception escapes… | Example |
|---|---|---|
| No-throw | It never does | Destructors, swap, move operations marked noexcept |
| Strong | State is exactly as before the call (“commit or roll back”) | std::vector::push_back, given a noexcept move or a copyable type |
| Basic | No leaks, invariants hold, but state may have changed | Most operations that modify several things |
A function that offers none of these can leave an object broken. This class keeps two containers that must describe the same items:
// strong.cpp - the same update written with and without the strong guarantee
#include <iostream>
#include <map>
#include <stdexcept>
#include <string>
#include <vector>
struct Catalog {
std::vector<std::string> names; // invariant: names and prices
std::map<std::string, int> prices; // always describe the same items
static int checked_price(int cents) {
if (cents <= 0) throw std::invalid_argument("price must be positive");
return cents;
}
void add_basic(const std::string& name, int cents) {
names.push_back(name); // state changes first...
prices[name] = checked_price(cents); // ...then something throws
}
void add_strong(const std::string& name, int cents) {
int price = checked_price(cents); // everything that can throw first
names.push_back(name); // may throw: nothing else changed yet
try {
prices.emplace(name, price); // may throw (allocation): undo the push
} catch (...) {
names.pop_back();
throw;
}
}
void report(const char* label) const {
std::cout << label << ": " << names.size() << " names, "
<< prices.size() << " prices\n";
}
};
int main()
{
Catalog a, b;
try { a.add_basic("cable", -5); } catch (const std::exception& e) { std::cout << "add_basic: " << e.what() << '\n'; }
try { b.add_strong("cable", -5); } catch (const std::exception& e) { std::cout << "add_strong: " << e.what() << '\n'; }
a.report("after add_basic ");
b.report("after add_strong");
}
Output:
add_basic: price must be positive
add_strong: price must be positive
after add_basic : 1 names, 0 prices
after add_strong: 0 names, 0 prices
add_basic changed names before discovering the price was invalid, and the Catalog was left with a name that has no price: the invariant the class exists to protect was broken, and the caller has no way to know. add_strong does everything that can fail before touching any state, and undoes its one earlier change if the second can still fail. The Catalog came out exactly as it went in. The general technique is do the work on the side, then commit with operations that cannot throw; copy-and-swap, shown in the guide to operator overloading in C++, is the same idea applied to assignment. Keeping invariants intact is also the core of encapsulation.
noexcept: What It Promises and What It Changes
noexcept marks a function as never letting an exception escape. It is not documentation: code can ask whether an operation is noexcept and change its behavior, and the standard library does. When a std::vector grows, it must move its elements to new storage. If moving an element could throw halfway through, push_back could no longer promise the strong guarantee, so the vector copies instead unless the move constructor is noexcept:
// moves.cpp - std::vector copies instead of moving unless the move is noexcept
#include <iostream>
#include <string>
#include <vector>
static long copies = 0, moves = 0;
template <bool NoexceptMove>
struct Row {
std::string text;
explicit Row(std::string t) : text(std::move(t)) {}
Row(const Row& other) : text(other.text) { ++copies; }
Row(Row&& other) noexcept(NoexceptMove) : text(std::move(other.text)) { ++moves; }
};
template <bool NoexceptMove>
void fill(const char* label)
{
copies = moves = 0;
std::vector<Row<NoexceptMove>> rows;
for (int i = 0; i < 1000; ++i)
rows.emplace_back("row " + std::to_string(i));
std::cout << label << ": " << copies << " copies, " << moves
<< " moves while growing to " << rows.size() << " rows\n";
}
int main()
{
fill<false>("move constructor without noexcept");
fill<true>("move constructor marked noexcept ");
}
Output (g++ and clang++, identical):
move constructor without noexcept: 1023 copies, 0 moves while growing to 1000 rows
move constructor marked noexcept : 0 copies, 1023 moves while growing to 1000 rows
The only difference between the two types is the word noexcept, and it turned 1,023 copies into 1,023 moves. With short strings the copies are cheap, so the same test was repeated with 100,000 rows of 1,000-character strings, timed at -O2 over five runs:
| Build | Move without noexcept (median) | Move with noexcept (median) |
|---|---|---|
| g++ 13.3 | 82.8 ms | 34.8 ms |
| clang++ 18.1.3 | 89.0 ms | 35.5 ms |
Both timings include creating the 100,000 strings, which is the same in both columns, so the ratio understates the cost of the copies themselves. The figures are from one machine (described under the cost benchmark below). The rule they support does not depend on the machine: mark move constructors and move assignment noexcept. The compiler-generated ones already are, when every member’s are.
Breaking the Promise
If an exception does reach a noexcept boundary, the program terminates, even if a handler is waiting further up:
// nx.cpp - an exception that reaches a noexcept boundary
#include <iostream>
#include <stdexcept>
struct Tracer {
const char* name;
explicit Tracer(const char* n) : name(n) {}
~Tracer() { std::cout << " drop " << name << std::endl; }
};
void parse(int n)
{
Tracer local("parse-local");
if (n < 0) throw std::invalid_argument("negative size");
}
void configure(int n) noexcept // promises never to throw
{
Tracer local("configure-local");
parse(n); // ...but calls something that does
}
int main()
{
try {
configure(-1);
} catch (const std::exception& e) { // never reached
std::cout << "caught: " << e.what() << '\n';
}
}
Output (g++ and clang++, at -O0 and -O2; exit status 134):
drop parse-local
terminate called after throwing an instance of 'std::invalid_argument'
what(): negative size
The catch in main never ran. Note also which destructors did: parse-local was destroyed, configure-local was not. Whether any unwinding happens before std::terminate is again implementation-defined; here the runtime unwound as far as the noexcept function and stopped. Neither compiler warned, because the throw is inside a different function.
Destructors are noexcept by default since C++11, so a destructor that throws ends the program the same way:
// dtor.cpp - a destructor that throws
#include <iostream>
#include <stdexcept>
struct Connection {
~Connection() {
throw std::runtime_error("close failed"); // destructors are noexcept by default
}
};
int main()
{
try {
Connection c;
} catch (const std::exception& e) { // never reached
std::cout << "caught: " << e.what() << '\n';
}
}
Output (exit status 134):
terminate called after throwing an instance of 'std::runtime_error'
what(): close failed
Both compilers warned, in different words:
warning: 'throw' will always call 'terminate' [-Wterminate]
note: in C++11 destructors default to 'noexcept' (g++)
warning: '~Connection' has a non-throwing exception specification but can still throw [-Wexceptions] (clang++)
If closing a resource can fail in a way the caller must know about, give the class an explicit close() that can throw, and let the destructor only clean up quietly.
Function-Try-Blocks
A try block inside a constructor’s body cannot catch an exception thrown while initializing its members, because the members are initialized before the body starts. A function-try-block wraps the initializer list too:
// ftb.cpp - a function-try-block around a constructor's member initializers
#include <iostream>
#include <stdexcept>
#include <string>
struct Config {
explicit Config(const std::string& path) {
throw std::runtime_error("cannot open " + path);
}
};
class Service {
public:
explicit Service(const std::string& path)
try : config_(path) { // the try covers the initializer list
std::cout << "Service body\n"; // not reached
} catch (const std::exception& e) {
std::cout << "Service could not start: " << e.what() << '\n';
// no return or throw here: the exception is rethrown automatically
}
private:
Config config_;
};
int main()
{
try {
Service s("/etc/app.conf");
} catch (const std::exception& e) {
std::cout << "main still sees it: " << e.what() << '\n';
}
}
Output:
Service could not start: cannot open /etc/app.conf
main still sees it: cannot open /etc/app.conf
The handler ran, but the exception still reached main. In a constructor’s function-try-block, the exception is always rethrown when the handler ends: an object whose member failed to construct cannot be allowed to exist. Use it to log or translate the exception, not to recover.
What Exceptions Cost, and std::expected
Exceptions are designed to cost almost nothing when nothing is thrown and a lot when something is. C++23’s std::expected<T, E> holds either a value or an error, returned like any other value. This benchmark calls the same trivial check both ways, once where it always succeeds and once where it always fails:
// cost.cpp - what a failure costs: exception vs std::expected (C++23)
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <expected>
#include <stdexcept>
__attribute__((noinline)) int check_throw(int v)
{
if (v < 0) throw std::invalid_argument("negative");
return v * 2;
}
__attribute__((noinline)) std::expected<int, int> check_expected(int v)
{
if (v < 0) return std::unexpected(1);
return v * 2;
}
template <class F>
double ns_per_call(F f, int n)
{
double best[5];
for (double& b : best) {
auto t0 = std::chrono::steady_clock::now();
f(n);
auto t1 = std::chrono::steady_clock::now();
b = std::chrono::duration<double, std::nano>(t1 - t0).count() / n;
}
std::sort(best, best + 5);
return best[2]; // median of 5
}
volatile long sink;
int main()
{
const int ok_n = 10'000'000, fail_n = 1'000'000;
auto throw_ok = [](int n) { long s = 0; for (int i = 0; i < n; ++i) s += check_throw(i); sink = s; };
auto exp_ok = [](int n) { long s = 0; for (int i = 0; i < n; ++i) s += *check_expected(i); sink = s; };
auto throw_fail = [](int n) {
long s = 0;
for (int i = 0; i < n; ++i) {
try { s += check_throw(-1); } catch (const std::invalid_argument&) { ++s; }
}
sink = s;
};
auto exp_fail = [](int n) {
long s = 0;
for (int i = 0; i < n; ++i) {
auto r = check_expected(-1);
s += r ? *r : 1;
}
sink = s;
};
std::printf("success path: exception %6.2f ns expected %6.2f ns\n",
ns_per_call(throw_ok, ok_n), ns_per_call(exp_ok, ok_n));
std::printf("failure path: exception %6.0f ns expected %6.2f ns\n",
ns_per_call(throw_fail, fail_n), ns_per_call(exp_fail, fail_n));
}
Output (one of five invocations, g++ 13.3, -std=c++23 -O2):
success path: exception 1.25 ns expected 1.26 ns
failure path: exception 1518 ns expected 1.91 ns
Across five invocations:
| Path | Exception | std::expected |
|---|---|---|
| Success (per call) | 1.25–1.62 ns | 1.26–1.43 ns |
| Failure (per call) | 1,395–1,543 ns | 1.82–2.67 ns |
The success path is the same within measurement noise. The failure path is not: each throw and catch took about 1.5 microseconds, 500 to 850 times the cost of returning an error value. A Clang 18 build of the exception half, with a plain struct in place of std::expected, measured 1,559–1,784 ns per throw, so the order of magnitude is not a GCC quirk.
How this was measured:
- Machine and build: a 2-vCPU cloud VM (Intel Xeon at 2.10 GHz, 8 GB RAM) running Ubuntu 24.04. g++ 13.3 with
-std=c++23 -O2. - Method: the checks are
noinline, so the compiler cannot delete the calls. Each figure is the median of five timed loops (10 million successful calls, or 1 million failing ones), and the table shows the range of those medians over five separate program runs. - Scope: a throw that unwinds through many frames costs more than this single-frame case; one that is caught immediately may cost less. There was no CPU pinning or frequency control.
The conclusion is not “exceptions are slow”. It is that exceptions are for failures that are rare. A parser that rejects half its input, or a lookup that often finds nothing, should report that through its return value.
std::expected in Practice
The same port parser, returning std::expected instead of throwing:
// expected.cpp - the same parser returning std::expected (C++23)
#include <charconv>
#include <expected>
#include <iostream>
#include <string_view>
enum class ParseError { not_a_number, out_of_range };
std::expected<int, ParseError> parse_port(std::string_view text)
{
int value = 0;
auto [end, ec] = std::from_chars(text.data(), text.data() + text.size(), value);
if (ec == std::errc::result_out_of_range)
return std::unexpected(ParseError::out_of_range);
if (ec != std::errc{} || end != text.data() + text.size())
return std::unexpected(ParseError::not_a_number);
if (value < 1 || value > 65535)
return std::unexpected(ParseError::out_of_range);
return value;
}
int main()
{
for (std::string_view input : {"8080", "http", "70000", "99999999999", "80x"}) {
auto port = parse_port(input);
if (port)
std::cout << input << " -> port " << *port << '\n';
else if (port.error() == ParseError::not_a_number)
std::cout << input << " -> not a number\n";
else
std::cout << input << " -> out of range\n";
}
try {
int p = parse_port("http").value(); // value() on an error throws
std::cout << p << '\n';
} catch (const std::bad_expected_access<ParseError>&) {
std::cout << "value() on an error threw std::bad_expected_access\n";
}
}
Output:
8080 -> port 8080
http -> not a number
70000 -> out of range
99999999999 -> out of range
80x -> not a number
value() on an error threw std::bad_expected_access
The error is part of the return type, so the caller cannot use the result without deciding what to do about a failure, and nothing unwinds. Two details from this run: std::from_chars rejected 80x, which std::stoi in the first example would have accepted as 80 (it stops at the first character that is not a digit), and calling value() on an error throws std::bad_expected_access, so std::expected interoperates with exceptions instead of replacing them. See cppreference’s std::expected for the full interface.
A compatibility note from testing: with Ubuntu 24.04’s default toolchain, this program compiled with g++ 13.3 but not with clang++ 18.1.3 (no template named 'expected' in namespace 'std'). The cause is the pairing, not Clang: libstdc++ 13 enables <expected> only when __cpp_concepts is at least 202002L, and Clang 18 reports 201907L. Check your compiler and standard library together before adopting std::expected.
Exceptions or Error Codes?
| Situation | Prefer | Why |
|---|---|---|
| A constructor cannot establish its invariant | Exception | A constructor has no return value; an object that is not valid must not exist |
| The failure is rare and the caller several layers up handles it | Exception | No error plumbing through intermediate functions; zero cost on the success path |
| The failure is common or expected (parsing input, lookups) | std::expected or std::optional | A throw cost about 1.5 µs here, against about 2 ns for a returned error |
| The caller must handle it immediately | std::expected | The error is part of the type and cannot be ignored silently |
Code built with exceptions disabled (-fno-exceptions), or across a C API | Error codes or std::expected | An exception must not propagate through C code |
| A bug in the program (a broken precondition) | An assertion or contract check | Not an error the caller can recover from |
Old Exception Features to Stop Using
Several features still appear in older C++ tutorials that the standard has since removed or deprecated. What happened when they were compiled for this article:
| Feature | Standard status | g++ 13.3 / clang++ 18.1.3 | Use instead |
|---|---|---|---|
Dynamic exception specification: void f() throw(X) | Removed in C++17 | Error in C++17 and C++20: ISO C++17 does not allow dynamic exception specifications | noexcept, or nothing |
throw() | Deprecated in C++11, removed in C++20 | Accepted without a diagnostic, even with -std=c++20 -pedantic | noexcept |
std::set_unexpected, std::unexpected() | Removed in C++17 | Still declared by libstdc++; deprecation warning | Nothing: noexcept violations call std::terminate |
std::uncaught_exception() | Removed in C++20 | Still declared by libstdc++; warning: use 'std::uncaught_exceptions()' instead | std::uncaught_exceptions() |
std::auto_ptr | Removed in C++17 | Still provided by libstdc++ with a deprecation warning | std::unique_ptr |
setjmp / longjmp across C++ objects | Undefined behavior if it skips a non-trivial destructor | Compiles; in testing, the skipped destructor did not run | Exceptions, or return values |
That a compiler still accepts one of these is not a reason to use it. The next standard library, or another compiler, may not, and the replacements are all better.
Common Mistakes
| Mistake | What happened in testing | Fix |
|---|---|---|
| Catching by value | what() returned std::exception; g++ warned, clang++ did not | catch (const T& e) |
throw e; to rethrow | The exception lost its type; no warning from either compiler | throw; |
| Base-class handler first | The specific handler never ran; both compilers warned | Most specific handler first |
Raw new in a constructor that can throw | 500,000 bytes leaked, no warning | RAII members (std::vector, std::unique_ptr) |
| Relying on unwinding with no handler | No destructors ran before the abort | A try block with catch (...) in main |
Move constructor without noexcept | std::vector copied 1,023 times instead of moving | Mark moves noexcept |
| Throwing from a destructor | std::terminate; both compilers warned | A separate close() that may throw |
| Exceptions for common outcomes | About 1.5 µs per failure | std::expected or std::optional |
Key Takeaways
throwstarts a search for a matchingcatch; every fully constructed local object on the way is destroyed, newest first.- If nothing catches, destructors may not run. With GCC and Clang, none did. Catch at the top of
mainif cleanup matters. - Catch by
constreference, rethrow withthrow;, and order handlers from specific to general. Two of those mistakes produced no warning from at least one compiler. - Derive your own exceptions from
std::runtime_errororstd::logic_error, so generic handlers still get a message.std::bad_allocis neither. - RAII is what makes exceptions safe. A constructor that threw after a raw
newleaked 500,000 bytes; with astd::vectormember it leaked nothing. - Aim for the strong guarantee: do the work that can fail first, then commit. The basic version left an object with a name and no price.
- Mark move operations
noexcept. Without it,std::vectorcopied instead of moving, and growth took 2.4 to 2.5 times as long in the test. - Exceptions cost about nothing when not thrown, and about 1.5 µs when thrown, in this benchmark. Use
std::expectedfor failures that are routine.
Frequently Asked Questions
Conclusion
The syntax of C++ exceptions is three keywords, and it is the easy part. The parts that decide whether exception-handling code is correct are about objects: which ones are destroyed when an exception passes (only the fully constructed ones, and only if someone catches), what state the survivors are left in (whatever the function’s guarantee says), and whether a noexcept promise is kept. Build classes from members that clean up after themselves, make moves and destructors non-throwing, and use exceptions for the failures that are genuinely exceptional.
The rest of the C++ programming tutorials cover the classes, smart pointers and standard containers that make RAII the default rather than the exception.


