Exception Safety Guarantees
When an exception is thrown, control leaves a function suddenly — and any work that function had started may be half-finished. Exception safety is about what guarantees your code makes about the program state after an exception escapes. There are three widely recognized levels, formalized by Abrahams and adopted throughout the C++ standard library: the basic guarantee, the strong guarantee, and the nothrow guarantee. Understanding these levels lets you reason about correctness under failure and design APIs that are safe by construction.
The three exception safety levels
No resource leaks; invariants preserved
If an exception is thrown, the program is left in a valid but unspecified state. All invariants still hold (no corrupted objects, no leaked memory), but the values of modified objects are not predictable. This is the minimum acceptable level. Any function that manages resources with RAII automatically gets the basic guarantee for free, because destructors run on unwinding.
Commit-or-rollback: operation either succeeds completely or has no effect
If an exception is thrown, the program state is exactly as it was before the call — the operation is fully rolled back. This is the ideal level for operations that modify state, because callers can safely retry or handle the error knowing the system is in the state it was before. The canonical way to achieve this is the copy-and-swap idiom.
Never throws; always succeeds
The operation is guaranteed never to throw an exception. This is required for destructors, swap functions, and move operations — and is the level you must achieve to make the strong guarantee achievable in others. Mark these noexcept.
The destructor rule: never throw
Destructors must never throw. When an exception is propagating, the runtime unwinds the stack, calling destructors for all local objects. If a destructor throws during this unwinding, C++ calls std::terminate()— there is no recovery. This is why every destructor should provide the nothrow guarantee, and the language enforces it: since C++11, destructors are implicitly noexcept by default.
class Resource {
~Resource() noexcept { // implicit, but good to be explicit
// Must NOT throw here — if an exception is already propagating,
// a second throw calls std::terminate()
close_handle(); // if close_handle can throw, catch it here:
try { close_handle(); } catch (...) { /* log, never propagate */ }
}
};Achieving the strong guarantee: copy-and-swap
The classic way to achieve the strong guarantee for copy assignment is to do all the work on a temporary copy first, then swap the result in using a noexcept swap. If the copy throws, the original is untouched. The swap never throws.
// Naive assignment — only basic guarantee:
Buffer& Buffer::operator=(const Buffer& other) {
if (this == &other) return *this;
delete[] data_; // (1) free old data — no going back
data_ = new int[other.size_]; // (2) if this throws, data_ is dangling!
size_ = other.size_;
std::copy(other.data_, other.data_ + size_, data_);
return *this;
}
// Strong guarantee via copy-and-swap:
Buffer& Buffer::operator=(Buffer other) noexcept {
// 'other' was constructed by the copy constructor (before we get here).
// If that copy threw, we never entered this function — the original is safe.
swap(*this, other); // noexcept swap, can't fail
return *this;
// old data is destroyed when 'other' goes out of scope
}
// The swap must be noexcept:
friend void swap(Buffer& a, Buffer& b) noexcept {
using std::swap;
swap(a.data_, b.data_);
swap(a.size_, b.size_);
}The parameter otheris taken by value — the copy constructor runs at the call site before the function body executes. If the copy throws, the caller's exception propagates and the original object was never touched. If it succeeds, the swap is guaranteed not to throw. The original data is then destroyed when other goes out of scope at the end of the function.
Exception safety in the standard library
The C++ standard library documents the exception safety level of every operation. Understanding these lets you reason about your own code that uses STL containers.
// std::vector::push_back — strong guarantee:
// If the element copy/move throws, the vector is unchanged.
// The element is only committed to the vector if the operation succeeds.
std::vector<std::string> v = {"hello", "world"};
v.push_back(risky_operation()); // if risky_operation() throws, v is unchanged
// std::vector reallocation:
// If T's move constructor is noexcept, vector moves elements on reallocation.
// If the move can throw, vector copies instead (to preserve the strong guarantee).
// This is why you should mark move constructors noexcept.
// std::map::insert — strong guarantee:
// Either the element is inserted and the map has one more element,
// or an exception is thrown and the map is unchanged.
std::map<int, std::string> m;
m.insert({42, "answer"}); // strong guarantee| Operation | Guarantee |
|---|---|
| vector::push_back | Strong (if T's copy/move is nothrow: nothrow) |
| vector::operator[] | Nothrow |
| vector::at() | Strong (throws std::out_of_range on bad index) |
| map::insert / emplace | Strong |
| std::swap (for standard types) | Nothrow |
| Destructors | Nothrow (required) |
Key rules to remember
Destructors must never throw — they are implicitly noexcept since C++11
If a destructor throws while another exception is unwinding the stack, std::terminate() is called. Catch and suppress exceptions inside destructors.
Move constructors and swap functions should always be noexcept
std::vector and other containers can only provide the strong guarantee during reallocation if the element's move is noexcept. A throwing move forces a copy.
Copy-and-swap achieves the strong guarantee for copy assignment
Take the RHS by value (the copy happens before the function body). If the copy throws, the original is untouched. Then swap with a noexcept swap.
Do all throwing work on temporaries before modifying state
If you must modify multiple things, build the complete new state in temporaries first. Only commit to the actual object once everything succeeded.