C++ Module 13 – Error handling
C++ exceptions: throw, catch and stack unwinding
Throw and catch C++ exceptions the right way, watch stack unwinding run destructors on two compilers, and choose the standard exception class to derive from.
What you will learn
- Catch exceptions by const reference and throw them by value
- Predict which destructors stack unwinding runs, and when it runs none
- Choose the standard exception class to derive your own exceptions from
- Predict when std::terminate ends a program instead of a handler
Before you start
What you’ll learn · Before you start
What you will learn
- Catch exceptions by const reference and throw them by value
- Predict which destructors stack unwinding runs, and when it runs none
- Choose the standard exception class to derive your own exceptions from
- Predict when std::terminate ends a program instead of a handler
Before you start
On this page
read_marks finds an empty line three calls below main, and neither it nor the two functions between it and main
can do anything useful about it. C++ reports such a failure with an exception: An object thrown with throw to report that a function cannot do what it was asked. Control leaves the function at once and passes up the chain of callers to the first handler whose type matches the object.: the function
creates an object that describes the problem and throws it, and control leaves at once for the nearest try block
up the chain of callers that has a matching catch handler. On the way,
stack unwinding: What happens while a thrown exception travels to its catch block: every local object constructed since the try block began, and not yet destroyed, is destroyed in reverse order before the handler runs. More in “Destructors and RAII in C++” destroys every local object of every function it leaves, newest
first, so RAII: Resource acquisition is initialisation: a class acquires a resource, such as an open file or a locked mutex, in its constructor and releases it in its destructor, so every path out of the object's scope releases it exactly once. More in “Destructors and RAII in C++” objects still close their files and release their locks although no function
returned normally. If no handler exists anywhere, the program calls std::terminate and ends.
Exceptions suit failures that the caller cannot sensibly check for at every step: a missing file, malformed input found
deep inside a parser, memory that ran out. Below, one throw is traced through three functions with Apple clang 21 and
GCC 16, caught four ways, matched against the standard classes, and stopped by noexcept and by builds without
exceptions.
A throw leaves every function between it and the handler
A try block marks the code whose exceptions you want to handle, and the
exception handler: A catch block after a try block, such as catch (const std::out_of_range& e). The handlers are tried in the order they are written; the first whose type is the thrown type, or a public base class of it, runs, and catch (...) matches any exception. after it say which ones. When an exception reaches the block, its handlers
are tried in the order they are written, and the first whose type matches runs: a handler for the same class as the
exception, or for a public base class of it. A handler written catch (...) matches any exception at all. After the
handler finishes, the program continues below the last handler of that try block; it never goes back to the code
that threw.
Tracer prints + when it is created and - when it is destroyed. Three functions each create one and call the
next, and the innermost one throws when the line it is given is empty:
C++ · unwind.cpp
#include <print>
#include <stdexcept>
#include <string_view>
#include "tracer.h"
void read_marks(std::string_view line) {
Tracer buffer{"read_marks: buffer"};
if (line.empty()) throw std::invalid_argument("empty line");
std::println(" read_marks read \"{}\"", line);
}
void load_student(std::string_view line) {
Tracer record{"load_student: record"};
read_marks(line);
std::println(" load_student finished"); // skipped when read_marks throws
}
void import_class(std::string_view line) {
Tracer log{"import_class: log"};
load_student(line);
std::println(" import_class finished"); // skipped too
}
int main(int argc, char*[]) {
if (argc > 1) { // ./unwind uncaught: the same throw, and no catch anywhere
std::println("an empty line, caught nowhere:");
import_class("");
return 0;
}
std::println("a good line:");
import_class("Asha 82");
std::println("an empty line, caught in main:");
try {
import_class("");
} catch (const std::invalid_argument& e) {
std::println(" main caught: {}", e.what());
}
} C header · tracer.h
// Tracer prints a line when it is created and another when it is destroyed, so that stack unwinding becomes visible.
#pragma once
#include <print>
#include <string>
#include <utility>
class Tracer {
public:
explicit Tracer(std::string name) : name_(std::move(name)) { std::println(" + {}", name_); }
~Tracer() { std::println(" - {}", name_); }
Tracer(const Tracer&) = delete;
Tracer& operator=(const Tracer&) = delete;
private:
std::string name_;
}; from session import run
# The same three calls with both compilers: a good line, a throw that main catches, and a throw nobody catches.
run("""
clang++ -std=c++23 -Wall -Wextra unwind.cpp -o unwind && ./unwind
./unwind uncaught
g++-16 -std=c++23 -Wall -Wextra unwind.cpp -o unwind-gcc && ./unwind-gcc
./unwind-gcc uncaught
""") Output
$ clang++ -std=c++23 -Wall -Wextra unwind.cpp -o unwind && ./unwind a good line: + import_class: log + load_student: record + read_marks: buffer read_marks read "Asha 82" - read_marks: buffer load_student finished - load_student: record import_class finished - import_class: log an empty line, caught in main: + import_class: log + load_student: record + read_marks: buffer - read_marks: buffer - load_student: record - import_class: log main caught: empty line $ ./unwind uncaught an empty line, caught nowhere: + import_class: log + load_student: record + read_marks: buffer libc++abi: terminating due to uncaught exception of type std::invalid_argument: empty line [exit status 134] $ g++-16 -std=c++23 -Wall -Wextra unwind.cpp -o unwind-gcc && ./unwind-gcc a good line: + import_class: log + load_student: record + read_marks: buffer read_marks read "Asha 82" - read_marks: buffer load_student finished - load_student: record import_class finished - import_class: log an empty line, caught in main: + import_class: log + load_student: record + read_marks: buffer - read_marks: buffer - load_student: record - import_class: log main caught: empty line $ ./unwind-gcc uncaught an empty line, caught nowhere: + import_class: log + load_student: record + read_marks: buffer terminate called after throwing an instance of 'std::invalid_argument' what(): empty line [exit status 134]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 unwind_session.py
Step through the second run, the empty line that main catches: each row is one call with the Tracer it holds,
and the order in which the three objects go is the point.
The drawing shows the whole scene; its 10 steps are listed below.
Every step as text
- The first call. Inside its try block, main calls import_class with an empty line. import_class creates log, a Tracer, which prints + import_class: log.
- Two more calls. load_student creates record and calls read_marks, which creates buffer. Each Tracer prints its + line, so three frames now sit above main, each holding one object.
- The throw. read_marks finds the line empty and throws std::invalid_argument("empty line"). The print below the throw never runs: control leaves read_marks at once.
- The search starts. Phase one, the search: the runtime looks for a handler that matches std::invalid_argument, frame by frame, and changes nothing. read_marks has no try block, so it looks in load_student next.
- Still searching. load_student has no try block either, so the search moves on to import_class. All three Tracers still exist: nothing has been destroyed yet.
- A handler matches. import_class has no handler, but main's try block has catch (const std::invalid_argument&), which matches the thrown type. Only now, with a handler found, does the cleanup start.
- buffer first. Phase two, the cleanup, destroys the newest object first: buffer prints - read_marks: buffer, and the frame of read_marks is gone.
- Then record. Next comes record, in load_student's frame. The rest of load_student is skipped, so load_student finished is never printed.
- Then log. log goes last, the oldest of the three, and import_class finished is skipped as well. Every object built since the try block began is now destroyed, in reverse order of construction.
- The handler runs. Only then does the catch block run: e.what() gives the message, and main prints main caught: empty line. The program goes on below the handler, never back into read_marks.
The first two runs show the normal path and stack unwinding:
- A good line. Each function finishes, and each
Traceris destroyed as its own function returns, so the-lines come out in the reverse order of the+lines. - An empty line, caught in
main.read_marksthrows astd::invalid_argument. Before the handler inmainruns, the three objects are destroyed, newest first:buffer, thenrecord, thenlog. The twofinishedlines never print, because the rest of each function was skipped.e.what()returns the message given to the exception’s constructor.
Both compilers print exactly the same lines, because the order is fixed by the standard: as control passes from the
throw to the handler, every local object constructed since the try block was entered is destroyed, in the reverse
order of construction.
Checkpoint
Check: A throw and the handler that catches it
Without a handler, no destructor runs
The third run threw the same exception with no try block anywhere. Both programs printed the three + lines and
then a message from their standard library, but no - line at all: not one destructor ran. Step through it and
watch the three Tracer objects on the right:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The same calls. ./unwind uncaught makes the same three calls, this time with no try block anywhere. The three Tracers print their + lines, as in the caught run.
- The same throw. read_marks throws std::invalid_argument("empty line"), exactly as before.
- The search. The search phase looks for a matching handler in read_marks, then in load_student and import_class. It changes nothing on the way.
- Nothing in main. main has no try block either. The search has reached the top of the stack without finding a handler.
- std::terminate. With no handler, the cleanup phase never starts. std::terminate runs with the stack exactly as it was at the throw, and the standard library prints its message.
- No destructor ran. log, record and buffer are never destroyed, so no - line appears. std::terminate calls std::abort, which ends the process with SIGABRT: exit status 134.
The standard leaves this case to the implementation: when no matching handler is found,
std::terminate: The function C++ calls when exception handling cannot go on, for example when no handler matches a throw or an exception tries to leave a noexcept function. By default it calls std::abort, which ends the program with SIGABRT. is called, and whether the stack is unwound first is
implementation-defined. Both compilers here follow the Itanium C++ ABI, which throws in two phases. A search phase
walks up the stack looking for a handler and changes nothing. Only if it finds one does a cleanup phase run the
destructors on the way to it. With no handler, the cleanup phase never starts, and std::terminate sees the stack
exactly as it was at the throw. The diagram puts the two outcomes side by side:
A throw in two phases, as both compilers implement it
Text description of the diagram
The diagram shows what happens after a throw, in the order both compilers on this page implement it (the Itanium C++ ABI).
- An exception is thrown in read_marks.
- Phase 1, the search: the runtime looks for a matching catch handler, frame by frame up the stack, without changing anything.
- A decision: was a handler found?
- If yes, phase 2, the cleanup: the local objects of every frame between the throw and the handler are destroyed, newest first, and then the catch block runs.
- If no, std::terminate is called at once. No destructor has run and the stack is still exactly as it was at the throw.
That has a practical consequence. A destructor that matters, such as one that writes the last buffered lines of a
log, runs after an error only if something catches the error. A handler in main that catches const std::exception&, prints the message and returns a non-zero exit status therefore does two jobs: the user gets a
readable message, and every destructor runs. Its cost is that the place of the throw is gone by the time the handler
runs, which matters when you want a stack trace of the failure; the lesson on debugging crashes, later in this
module, shows how to record the stack before the program ends.
Key idea
Unwinding needs a handler: the search must find a matching catch before the cleanup destroys anything, so an
exception that nothing catches runs no destructor at all with these two compilers.
Checkpoint
Check: Unwinding needs a handler
Catching by value slices the exception, and throw e; does too
A program can throw any copyable type, but a purpose-made class is clearer than a bare int or string, rule E.14 of
the C++ Core Guidelines says, because its type names the problem and its members carry the details. ParseError is a
std::runtime_error that also knows the line where parsing failed. The same exception is caught here in four ways:
#include <print>
#include <stdexcept>
#include <string>
// A purpose-made exception type: a std::runtime_error that also knows where the problem is.
class ParseError : public std::runtime_error {
public:
ParseError(const std::string& message, int line) : std::runtime_error(message), line_(line) {}
int line() const noexcept { return line_; }
private:
int line_;
};
void parse_settings() { throw ParseError("expected '=' after the key", 7); }
int main() {
try {
parse_settings();
} catch (std::exception e) { // by value: a new std::exception, copied from part of the ParseError
std::println("by value: {}", e.what());
}
try {
parse_settings();
} catch (const std::exception& e) { // by reference: the object that was thrown
std::println("by reference: {}", e.what());
}
for (bool copy : {false, true}) {
try {
try {
parse_settings();
} catch (const std::exception& e) {
if (copy) throw e; // throws a copy of the std::exception part
throw; // rethrows the ParseError itself
}
} catch (const ParseError& e) {
std::println("{:<13} still a ParseError, line {}", copy ? "throw e;" : "throw;", e.line());
} catch (const std::exception& e) {
std::println("{:<13} only a std::exception now: {}", copy ? "throw e;" : "throw;", e.what());
}
}
} from session import run
# Catching by value and by reference, then rethrowing with throw; and throw e;, with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra catching.cpp -o catching && ./catching
g++-16 -std=c++23 -Wall -Wextra catching.cpp -o catching-gcc && ./catching-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra catching.cpp -o catching && ./catching
by value: std::exception
by reference: expected '=' after the key
throw; still a ParseError, line 7
throw e; only a std::exception now: std::exception
$ g++-16 -std=c++23 -Wall -Wextra catching.cpp -o catching-gcc && ./catching-gcc
catching.cpp: In function 'int main()':
catching.cpp:20:29: warning: catching polymorphic type 'class std::exception' by value [-Wcatch-value=]
20 | } catch (std::exception e) { // by value: a new std::exception, copied from part of the ParseError
| ^
by value: std::exception
by reference: expected '=' after the key
throw; still a ParseError, line 7
throw e; only a std::exception now: std::exception
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 catching_session.py
Step through the four parts, with the thrown ParseError on the left and what each handler receives on the right:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The thrown object. parse_settings throws ParseError("expected '=' after the key", 7). The object has three layers: its std::exception base, the message that std::runtime_error keeps, and ParseError's own line_, 7.
- By value. catch (std::exception e) creates a new std::exception and copies only the std::exception part into it. The copy is sliced: e.what() is the base class's own text, so the program prints by value: std::exception.
- By const reference. catch (const std::exception& e) binds e to the object that was thrown, all three layers. Its virtual what() is the ParseError's, so the line printed is by reference: expected '=' after the key.
- throw;. Inside a handler, throw; with no operand rethrows the exception being handled, the ParseError itself. The outer catch (const ParseError&) matches and reads line 7.
- throw e;. throw e; throws a new copy of e's static type, std::exception. The outer ParseError handler no longer matches; the std::exception handler after it catches the copy, and the line is lost: only a std::exception now.
- By value,
catch (std::exception e)creates a newstd::exceptionand copies into it only thestd::exceptionpart of the thrownParseError. The copy is slicing: What happens when an object of a derived class is copied into a variable or a parameter of a base class type: only the base part is copied, the derived members are left behind, and the copy behaves as a plain base object. More in “Virtual functions, override and virtual destructors”: itswhat()is the base class’s own message,std::exceptionwith both libraries, and the line number is gone. GCC warns about it with-Wcatch-value, which-Wallturns on; Clang compiles it silently. - By const reference, the handler sees the object that was thrown, with its real message. A handler for a base
class can still reach the derived members with
dynamic_cast, or by catchingconst ParseError&directly. throw;with no operand rethrow: throw; with no operand, written inside a handler: it throws the exception being handled again, the same object with its full type, so an outer handler for the derived class still matches. throw e; throws a copy of e's static type instead. the exception being handled, theParseErroritself, so the outer handler forParseErrorstill matches.throw e;throws a new copy ofe’s static type: The type the compiler sees for an expression, from the declarations alone: through a Shape* the object has the static type Shape, whatever it really is. Calls to ordinary member functions are chosen from it. More in “Virtual functions, override and virtual destructors”,std::exception, so the outerParseErrorhandler no longer matches and the line number is lost again.
Rule E.15 of the Core Guidelines sums it up: throw by value, catch exceptions from a class hierarchy by reference,
usually const. Never write throw new ParseError(…), which throws a pointer that some handler must remember to
delete.
Catching by value
catch (std::exception e) turns a thrown ParseError into a plain std::exception: what() says only
std::exception, and the line is gone. Write catch (const std::exception& e), and pass the exception on with
throw;, never throw e;.
Checkpoint
Check: Catching by reference, rethrowing with throw;
Decide by the exception’s type, never by its message
The standard library reports its own failures with a small family of classes, all derived from std::exception,
whose virtual what() returns a message:
The standard exception classes most programs meet
Text description of the diagram
The diagram is a family tree of standard exception classes, with std::exception at the top. Its virtual member function what() returns the message.
- std::logic_error, for mistakes the caller could have avoided by checking first. Its subclasses: invalid_argument, domain_error, length_error and out_of_range.
- std::runtime_error, for failures that are found only while the program runs. Its subclasses: range_error and overflow_error, format_error, and system_error. filesystem_error derives from system_error.
- Classes that derive from std::exception directly: bad_alloc, bad_cast, bad_optional_access and bad_variant_access.
Every class shown is in namespace std; filesystem_error is std::filesystem::filesystem_error.
std::logic_error is for mistakes the caller could have avoided by checking first, such as an index past the
end. std::runtime_error is for failures that can only be found while the program runs, whatever the caller
does, such as a missing file. Here eight library calls are made in turn, and each handler names the class it caught:
#include <cstddef>
#include <filesystem>
#include <new>
#include <print>
#include <stdexcept>
#include <string>
#include <vector>
// Runs one call and reports which standard exception came out of it. The handlers go from the most derived
// classes to std::exception, because the first handler that matches wins.
template <typename Call>
void report(const char* what, Call call) {
try {
call();
std::println("{:<30} no exception", what);
} catch (const std::out_of_range& e) {
std::println("{:<30} out_of_range: {}", what, e.what());
} catch (const std::invalid_argument& e) {
std::println("{:<30} invalid_argument: {}", what, e.what());
} catch (const std::length_error& e) {
std::println("{:<30} length_error: {}", what, e.what());
} catch (const std::runtime_error& e) {
std::println("{:<30} a runtime_error: {}", what, e.what());
} catch (const std::bad_alloc& e) {
std::println("{:<30} bad_alloc: {}", what, e.what());
} catch (const std::exception& e) {
std::println("{:<30} another exception: {}", what, e.what());
}
}
int main() {
std::vector<int> marks{72, 64, 91};
report("marks.at(2)", [&] { (void)marks.at(2); });
report("marks.at(3)", [&] { (void)marks.at(3); });
report("std::stoi(\"seventy\")", [] { (void)std::stoi("seventy"); });
report("std::stoi(\"99999999999\")", [] { (void)std::stoi("99999999999"); });
report("std::string(\"abc\").substr(5)", [] { (void)std::string("abc").substr(5); });
report("marks.reserve(max_size + 1)", [&] { marks.reserve(marks.max_size() + 1); });
report("operator new(1 PiB)", [] { ::operator delete(::operator new(std::size_t{1} << 50)); });
report("file_size(\"missing.txt\")", [] { (void)std::filesystem::file_size("missing.txt"); });
} from session import run
# Which standard exception each library call throws, and the message each standard library gives it.
run("""
clang++ -std=c++23 -Wall -Wextra standard.cpp -o standard && ./standard
g++-16 -std=c++23 -Wall -Wextra standard.cpp -o standard-gcc && ./standard-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra standard.cpp -o standard && ./standard
marks.at(2) no exception
marks.at(3) out_of_range: vector
std::stoi("seventy") invalid_argument: stoi: no conversion
std::stoi("99999999999") out_of_range: stoi: out of range
std::string("abc").substr(5) out_of_range: basic_string
marks.reserve(max_size + 1) length_error: vector
operator new(1 PiB) bad_alloc: std::bad_alloc
file_size("missing.txt") a runtime_error: filesystem error: in file_size: No such file or directory ["missing.txt"]
$ g++-16 -std=c++23 -Wall -Wextra standard.cpp -o standard-gcc && ./standard-gcc
marks.at(2) no exception
marks.at(3) out_of_range: vector::_M_range_check: __n (which is 3) >= this->size() (which is 3)
std::stoi("seventy") invalid_argument: stoi
std::stoi("99999999999") out_of_range: stoi
std::string("abc").substr(5) out_of_range: string::string: __pos (which is 5) > this->size() (which is 3)
marks.reserve(max_size + 1) length_error: vector::reserve
operator new(1 PiB) bad_alloc: std::bad_alloc
file_size("missing.txt") a runtime_error: filesystem error: cannot get file size: No such file or directory [missing.txt]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 standard_session.py
Every failing call threw the same class with both libraries, the class the standard specifies for it: at and substr
throw std::out_of_range for a position past the end, std::stoi throws std::invalid_argument when there is no
number and std::out_of_range when the number does not fit in an int, reserve throws std::length_error, a
failed allocation throws std::bad_alloc, and file_size throws std::filesystem::filesystem_error, which the
std::runtime_error handler caught because it derives from std::system_error, a kind of std::runtime_error.
The messages are another matter. For marks.at(3), libc++ says vector, while libstdc++ says
vector::_M_range_check: __n (which is 3) >= this->size() (which is 3). The standard specifies the class of each
exception, never the text of its message, so code should decide what to do from the type it catches and show
what() only to people.
The order of the handlers in report matters too. They go from the most derived classes to std::exception,
because the first match wins: a std::logic_error handler placed before std::out_of_range would catch every
out-of-range error itself. Rule E.31 of the Core Guidelines asks for exactly this order.
For your own exception types, derive from std::logic_error or std::runtime_error, or from one of their
subclasses, and add the members a handler needs, as ParseError adds its line. Callers can then catch your exact
type where they can do something specific, and std::exception where they only report.
Key idea
The standard fixes the class of every exception it throws, never its message: catch by type, most derived class
first, and show what() only to people.
Checkpoint
Check: The standard exception classes
An exception that leaves a noexcept function ends the program
noexcept: A specifier that promises a function lets no exception out, as in Buffer(Buffer&&) noexcept. If one tries to leave such a function, std::terminate ends the program. A destructor is noexcept unless it says otherwise. More in “Destructors and RAII in C++” after a function’s parameters promises that no exception will leave the
function. The compiler relies on the promise, and the standard backs it with a hard rule: if the search for a handler
reaches the outermost block of a non-throwing function, std::terminate is called. checked_div breaks its promise:
#include <print>
#include <stdexcept>
int checked_div(int a, int b) noexcept { // promises never to throw...
if (b == 0) throw std::domain_error("division by zero"); // ...and breaks the promise
return a / b;
}
int main() {
try {
std::println("{}", checked_div(10, 2));
std::println("{}", checked_div(1, 0));
} catch (const std::exception& e) {
std::println("caught: {}", e.what()); // never reached
}
} from session import run
# A throw that leaves a noexcept function: both compilers warn, and both programs end in std::terminate.
run("""
clang++ -std=c++23 -Wall -Wextra noexcept_escape.cpp -o noexcept_escape && ./noexcept_escape
g++-16 -std=c++23 -Wall -Wextra noexcept_escape.cpp -o noexcept_escape-gcc && ./noexcept_escape-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra noexcept_escape.cpp -o noexcept_escape && ./noexcept_escape
noexcept_escape.cpp:5:17: warning: 'checked_div' has a non-throwing exception specification but can still throw [-Wexceptions]
5 | if (b == 0) throw std::domain_error("division by zero"); // ...and breaks the promise
| ^
noexcept_escape.cpp:4:5: note: function declared non-throwing here
4 | int checked_div(int a, int b) noexcept { // promises never to throw...
| ^ ~~~~~~~~
1 warning generated.
5
libc++abi: terminating due to uncaught exception of type std::domain_error: division by zero
Abort trap: 6
[exit status 134]
$ g++-16 -std=c++23 -Wall -Wextra noexcept_escape.cpp -o noexcept_escape-gcc && ./noexcept_escape-gcc
noexcept_escape.cpp: In function 'int checked_div(int, int)':
noexcept_escape.cpp:5:17: warning: 'throw' will always call 'terminate' [-Wterminate]
5 | if (b == 0) throw std::domain_error("division by zero"); // ...and breaks the promise
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
5
terminate called after throwing an instance of 'std::domain_error'
what(): division by zero
Abort trap: 6
[exit status 134]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 noexcept_session.py
Step through the run. main has a handler that matches the thrown std::domain_error, so watch whether it ever runs:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Warned while compiling. Both compilers see the broken promise before the program runs: Clang warns with -Wexceptions and GCC with -Wterminate, both on by default. The program still builds.
- checked_div(10, 2). Inside its try block, main calls checked_div(10, 2). The divisor is not 0, so nothing is thrown.
- 5. checked_div returns 5, and main prints 5, the first line of the run.
- checked_div(1, 0). The second call passes 0 as the divisor.
- The throw. b is 0, so checked_div throws std::domain_error("division by zero"), although its noexcept promised that no exception would ever leave it.
- The promise wins. The search for a handler reaches the outermost block of a non-throwing function. The standard requires std::terminate there, so main's matching handler is never tried and caught: is never printed.
- Exit status 134. Each library prints its own message, then std::abort ends the process with SIGABRT, signal 6: the shell reports exit status 134, which is 128 plus 6.
Both compilers see it coming, with Clang’s -Wexceptions and GCC’s -Wterminate, both on by default. At run time,
the first call prints 5, and the second ends the program although main has a handler that matches: the exception
never got past checked_div. Each library prints its own message, libc++’s terminating due to uncaught exception of type std::domain_error: division by zero and libstdc++’s terminate called after throwing an instance of 'std::domain_error' with the what() text below it. Both then abort the program:
exit status: The number a program hands back when it ends, the value main returns. 0 means success; a shell keeps the last status in $?, and && runs the next command only after a status of 0. More in “How C++ compiles: preprocess, compile and link” 134 is 128 plus 6, the number of SIGABRT.
The same ending awaits an exception that leaves main, as the third run of the unwinding example showed, and one
that leaves a destructor, because destructors are noexcept by default. So mark a function noexcept only when it
really cannot throw, as moves, swaps and destructors should not.
Checkpoint
Check: noexcept and std::terminate
Without exceptions, errors have to travel as values
Some large code bases build with -fno-exceptions. The LLVM project does, to reduce code and executable size, and
Google’s C++ style guide does not use exceptions at all. In such a build, throw and try no longer compile, and a
library function that would throw has no way to report its error:
C++ (thrower.cpp) · thrower.cpp
#include <stdexcept>
int checked_percent(int marks) {
if (marks < 0 || marks > 100) throw std::out_of_range("marks must be 0 to 100");
return marks;
}
int main() { return checked_percent(72) == 72 ? 0 : 1; } C++ (bounds.cpp) · bounds.cpp
#include <print>
#include <vector>
int main() {
std::vector<int> marks{72, 64, 91};
std::println("{}", marks.at(1));
std::println("{}", marks.at(3)); // at() checks the index and would throw std::out_of_range
std::println("not reached");
} from session import run
# Builds with exceptions turned off: throw no longer compiles, and a library call that would throw ends the program.
run("""
clang++ -std=c++23 -fno-exceptions thrower.cpp -o thrower
g++-16 -std=c++23 -fno-exceptions thrower.cpp -o thrower-gcc
clang++ -std=c++23 -fno-exceptions bounds.cpp -o bounds && ./bounds
g++-16 -std=c++23 -fno-exceptions bounds.cpp -o bounds-gcc && ./bounds-gcc
""") Output
$ clang++ -std=c++23 -fno-exceptions thrower.cpp -o thrower
thrower.cpp:4:35: error: cannot use 'throw' with exceptions disabled
4 | if (marks < 0 || marks > 100) throw std::out_of_range("marks must be 0 to 100");
| ^
1 error generated.
[exit status 1]
$ g++-16 -std=c++23 -fno-exceptions thrower.cpp -o thrower-gcc
thrower.cpp: In function 'int checked_percent(int)':
thrower.cpp:4:83: error: exception handling disabled, use '-fexceptions' to enable
4 | if (marks < 0 || marks > 100) throw std::out_of_range("marks must be 0 to 100");
| ^
[exit status 1]
$ clang++ -std=c++23 -fno-exceptions bounds.cpp -o bounds && ./bounds
64
Abort trap: 6
[exit status 134]
$ g++-16 -std=c++23 -fno-exceptions bounds.cpp -o bounds-gcc && ./bounds-gcc
64
terminate called after throwing an instance of 'std::out_of_range'
what(): vector::_M_range_check: __n (which is 3) >= this->size() (which is 3)
Abort trap: 6
[exit status 134]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 no_exceptions_session.py
The two toolchains agree about your own throw and disagree about the library’s:
With -fno-exceptions |
Apple clang 21, libc++ | GCC 16, libstdc++ |
|---|---|---|
A throw in your code |
Does not compile | Does not compile, other wording |
marks.at(3) on 3 marks |
abort(), nothing printed |
std::terminate and its message |
How bounds ends |
Exit status 134 | Exit status 134 |
Both compilers reject throw, each with its own wording. The call to at(3) ends both programs, in different ways.
libc++’s headers replace the throw with a plain abort(), which prints nothing. libstdc++’s range check lives in its
compiled library, which was built with exceptions, so the exception is thrown anyway, finds no handler in code
compiled without them, and std::terminate prints the usual message. Either way, code built like this reports its
errors as values instead: std::expected and error codes, the subject of a later lesson in this module.
Checkpoint
Check: Builds without exceptions
Practise: a safe_divide that reports division by zero
divide.h declares DivisionByZero, a std::domain_error whose message names the number that could not be
divided, and three functions that do not use it yet:
C header · divide.h
// Your turn (lesson cpp/exceptions-basics): make safe_divide report a zero divisor with an exception, and make
// class_averages recover from it. The sample tests are in divide_tests.cpp.
#pragma once
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
// What safe_divide throws. It is a std::domain_error, so a caller can also catch it as a std::logic_error or a
// std::exception, and what() names the dividend: "cannot divide 84 by zero".
class DivisionByZero : public std::domain_error {
public:
explicit DivisionByZero(int dividend)
: std::domain_error("cannot divide " + std::to_string(dividend) + " by zero"), dividend_(dividend) {}
int dividend() const noexcept { return dividend_; }
private:
int dividend_;
};
// TODO 1: when divisor is 0, throw DivisionByZero{dividend} instead of returning 0.
inline int safe_divide(int dividend, int divisor) {
if (divisor == 0) return 0;
return dividend / divisor;
}
struct ClassMarks {
std::string name;
int total_marks;
int students;
};
struct Averages {
std::vector<std::pair<std::string, int>> averages; // the class and its average, for every class with students
std::vector<std::string> skipped; // the classes that have no students
};
// TODO 2: a class with no students has no average. Catch DivisionByZero for that one class (by const reference),
// add the class's name to skipped and go on with the next class. Let every other exception reach the caller.
inline Averages class_averages(const std::vector<ClassMarks>& classes) {
Averages result;
for (const ClassMarks& c : classes) result.averages.emplace_back(c.name, safe_divide(c.total_marks, c.students));
return result;
}
// TODO 3: the exit status of a report program: 0 when every class has an average, 2 when any class was skipped.
inline int exit_status(const Averages& result) {
(void)result;
return 0;
} C++ · divide_tests.cpp
// Sample tests for divide.h. Build: clang++ -std=c++23 -Wall -Wextra divide_tests.cpp -o divide_tests
#include <print>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
#include "divide.h"
int passed = 0;
int failed = 0;
void expect(bool ok, const char* what) {
if (ok) {
++passed;
} else {
++failed;
std::println("FAIL {}", what);
}
}
int main() {
expect(safe_divide(84, 4) == 21, "safe_divide(84, 4) is 21");
bool threw = false;
std::string message;
int dividend = 0;
try {
(void)safe_divide(7, 0);
} catch (const DivisionByZero& e) {
threw = true;
message = e.what();
dividend = e.dividend();
}
expect(threw, "safe_divide(7, 0) throws DivisionByZero");
expect(message == "cannot divide 7 by zero" && dividend == 7, "the exception names the dividend 7");
bool as_logic_error = false;
try {
(void)safe_divide(1, 0);
} catch (const std::logic_error&) {
as_logic_error = true;
}
expect(as_logic_error, "the exception can be caught as a std::logic_error");
std::vector<ClassMarks> classes{{"9A", 840, 20}, {"9B", 0, 0}, {"9C", 1000, 25}};
Averages result = class_averages(classes);
std::vector<std::pair<std::string, int>> want{{"9A", 42}, {"9C", 40}};
expect(result.averages == want && result.skipped == std::vector<std::string>{"9B"},
"class_averages skips 9B, which has no students, and keeps 9A and 9C");
expect(exit_status(result) == 2, "the exit status is 2 when a class was skipped");
expect(exit_status(class_averages({{"10A", 750, 25}})) == 0, "the exit status is 0 when every class has an average");
std::println("{} of {} checks passed", passed, passed + failed);
return failed == 0 ? 0 : 1;
} from session import run
# The sample tests against the starter code, before the TODOs are done.
run("""
clang++ -std=c++23 -Wall -Wextra divide_tests.cpp -o divide_tests && ./divide_tests
g++-16 -std=c++23 -Wall -Wextra divide_tests.cpp -o divide_tests-gcc && ./divide_tests-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra divide_tests.cpp -o divide_tests && ./divide_tests FAIL safe_divide(7, 0) throws DivisionByZero FAIL the exception names the dividend 7 FAIL the exception can be caught as a std::logic_error FAIL class_averages skips 9B, which has no students, and keeps 9A and 9C FAIL the exit status is 2 when a class was skipped 2 of 7 checks passed [exit status 1] $ g++-16 -std=c++23 -Wall -Wextra divide_tests.cpp -o divide_tests-gcc && ./divide_tests-gcc FAIL safe_divide(7, 0) throws DivisionByZero FAIL the exception names the dividend 7 FAIL the exception can be caught as a std::logic_error FAIL class_averages skips 9B, which has no students, and keeps 9A and 9C FAIL the exit status is 2 when a class was skipped 2 of 7 checks passed [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 divide_session.py
Make all 7 checks pass with both compilers:
- In
safe_divide,throw DivisionByZero{dividend};whendivisoris 0. Throw the object itself, by value. - In
class_averages, put the call for one class inside atryblock in the loop, and catchconst DivisionByZero&there: add the class’s name toskippedand let the loop go on. Do not catchstd::exception, so that any other error still reaches the caller. - In
exit_status, return 2 whenskippedis not empty, and 0 otherwise, so that a script that runs the report can tell a partial result from a complete one.
The check that catches std::logic_error passes only because DivisionByZero derives from std::domain_error,
which derives from std::logic_error: a handler for a base class matches every class below it.
Finish with the self-check on the six ideas above, from the first matching handler to builds without exceptions. It starts where your answers have been weakest and explains each choice as soon as you make it:
Practice that adapts to you
Practise this lesson
12 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · Why do you throw by value and catch by const reference?
Throwing by value, as in throw ParseError{"missing key", 7};, lets the runtime own a copy of the exception for as
long as handlers need it, with nothing to delete. Catching by reference then refers to that object itself. Catching
by value instead copies only the part the handler names: catch (std::exception e) slices a ParseError down to a
plain std::exception, so what() loses its message and the extra members are gone, and GCC warns about it with
-Wcatch-value. const adds that handlers rarely need to change the exception. A good answer adds the rethrow rule:
throw; passes on the original object, while throw e; throws a sliced copy. Both rules are E.15 of the C++ Core
Guidelines.
The follow-ups usually test the two mistakes this answer rules out: throwing a pointer, and reaching the derived members from a base-class handler. Decide both:
Checkpoint
Interview round: two follow-up questions
Two design questions of this module start from this lesson: a batch job that dies with exit status 134 and loses the last lines of its log, and the exception classes of a library that several teams will use.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A batch job dies with "terminate called after throwing an instance of 'std::runtime_error'" and exit status 134. Its log, written by a class that flushes buffered lines in its destructor, is missing the last lines. Explain both symptoms and what you would change.
-
Mid to senior
You are designing the exceptions of a payments client library that several teams will use. Which classes do you define, what do they derive from, what data do they carry, and how should callers in different layers catch them?
Key takeaways
throwsends an exception up the call chain to the first matching handler; the handlers of atryblock are tried in order, so put derived classes before their bases.- Stack unwinding destroys every local object between the throw and the handler, newest first. With no handler at
all, both compilers call
std::terminatebefore any destructor runs. - Throw by value and catch by const reference: catching by value slices the exception, and
throw e;slices a rethrow, whilethrow;keeps the original. - Derive your exceptions from
std::logic_errororstd::runtime_errorand decide by type, never by parsingwhat(): the two standard libraries word their messages differently. - An exception that leaves a
noexceptfunction, a destructor ormaincallsstd::terminate, which aborts with exit status 134. - With
-fno-exceptions,throwdoes not compile and a library call that would throw ends the program, so errors have to travel as values.
Terms in this lesson
- exception
- An object thrown with throw to report that a function cannot do what it was asked. Control leaves the function at once and passes up the chain of callers to the first handler whose type matches the object.
- exception handler
- A catch block after a try block, such as catch (const std::out_of_range& e). The handlers are tried in the order they are written; the first whose type is the thrown type, or a public base class of it, runs, and catch (...) matches any exception.
- rethrow
- throw; with no operand, written inside a handler: it throws the exception being handled again, the same object with its full type, so an outer handler for the derived class still matches. throw e; throws a copy of e's static type instead.
- std::terminate
- The function C++ calls when exception handling cannot go on, for example when no handler matches a throw or an exception tries to leave a noexcept function. By default it calls std::abort, which ends the program with SIGABRT.
References
- Working Draft, Programming Languages: C++ (N4950): [except.throw], [except.ctor], [except.handle], [except.spec], [except.terminate], [std.exceptions] (ISO/IEC JTC1/SC22/WG21)
- Itanium C++ ABI: Exception Handling (Itanium C++ ABI project)
- C++ Core Guidelines: E.2, throw an exception to signal that a function cannot perform its assigned task (Standard C++ Foundation)
- C++ Core Guidelines: E.14, use purpose-designed user-defined types as exceptions (Standard C++ Foundation)
- C++ Core Guidelines: E.15, throw by value, catch exceptions from a hierarchy by reference (Standard C++ Foundation)
- C++ Core Guidelines: E.31, properly order your catch-clauses (Standard C++ Foundation)
- Options Controlling C++ Dialect (-Wcatch-value, -Wterminate) (GNU Project)
- Options for Code Generation Conventions (-fexceptions) (GNU Project)
- The GNU C++ Library manual: Exceptions (doing without) (GNU Project)
- Diagnostic flags in Clang (-Wexceptions) (LLVM Project)
- LLVM Coding Standards: do not use RTTI or exceptions (LLVM Project)
- Google C++ Style Guide: Exceptions (Google)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress