C++ Exception Safety — Basic, Strong, and Nothrow Guarantees
Key takeaways
A guide to C++ exception safety: what guarantees mean, how they relate to RAII and noexcept, and practical patterns (copy-and-swap, safe updates) with code.
What is exception safety?
Exception safety is about which program state you preserve when an exception is thrown—especially no resource leaks and consistent object invariants.
Example implementations of func:
// ❌ Not exception-safe
void func() {
int* ptr = new int(10);
process(); // leak if this throws
delete ptr;
}
// ✅ Exception-safe
void func() {
auto ptr = std::make_unique<int>(10);
process(); // unique_ptr cleans up on any exit path
}
The reason exception safety deserves its own vocabulary, rather than just “handle errors correctly,” is that C++ exceptions can unwind the stack through code that never explicitly checks for failure. In the unsafe func() above, process() doesn’t need to know anything about ptr — if it throws, the stack unwinds past the delete ptr; line entirely, and that memory leaks silently. There’s no error code to check and no obvious place the bug lives; it only shows up as slowly rising memory usage under whatever conditions make process() throw. This is why exception safety is really a design discipline about what happens when you don’t check for failure, not a checklist you apply after the fact.
Levels of guarantee
// 1. Basic guarantee
// - No resource leaks
// - Object remains in a valid (though possibly altered) state; invariants hold
// 2. Strong guarantee
// - Operation succeeds fully, or the observable state is unchanged
// - Often described as "commit or rollback" / atomic effect on state
// 3. Nothrow guarantee
// - No exceptions propagate from this operation
// - Often expressed with noexcept
These three levels form a hierarchy, and it’s worth being honest about the tradeoffs at each rung rather than reflexively reaching for the strongest one. Nothrow is the strongest and the cheapest to reason about, but it’s often impossible to provide honestly — anything that allocates memory can throw std::bad_alloc, so nothrow is realistically limited to operations like swaps, moves, and destructors that are carefully written to avoid allocation. The strong guarantee is the one people mean when they say “exception-safe” without qualification, and copy-and-swap (shown below) is the standard way to get it almost for free. The basic guarantee is the floor — it’s what you get automatically from RAII with no extra design work, and for most code, that’s genuinely enough; not every operation needs to be rollback-safe, only the ones where a half-applied change would corrupt something a caller depends on.
Each guarantee level in code
Basic guarantee only: the leak on a failed copy
class Buffer {
int* data;
size_t size;
public:
void resize(size_t newSize) {
int* newData = new int[newSize]; // may throw
size_t copySize = std::min(size, newSize);
std::copy(data, data + copySize, newData);
delete[] data;
data = newData;
size = newSize;
}
};
This version is only basic-safe, and it’s worth seeing exactly why: if std::copy throws partway through (unlikely for int, realistic if data held a type with a throwing copy constructor), newData has already been allocated and partially filled, and it leaks — delete[] newData never runs. The object itself doesn’t end up corrupted (the old data/size are untouched, since we haven’t reassigned them yet), which is why this still meets the basic guarantee’s bar of “no invariant violations.” But “no leaks” is also part of the basic guarantee’s definition, and this code fails that half of it. That gap between “looks basic-safe” and “is actually basic-safe” is exactly the kind of thing code review needs to catch, because the compiler won’t.
Strong guarantee with manual cleanup on copy failure
class Buffer {
int* data;
size_t size;
public:
void resize(size_t newSize) {
int* newData = new int[newSize];
try {
std::copy(data, data + std::min(size, newSize), newData);
} catch (...) {
delete[] newData; // clean up partial work
throw; // rethrow
}
delete[] data;
data = newData;
size = newSize;
}
};
Fixing the leak with an explicit try/catch around the risky operation works, but notice how much boilerplate it takes to get there — a try block, a catch (...), an explicit delete[], and a throw; to propagate the original exception unchanged. Multiply that by every resource-acquiring operation in a real class and the manual approach becomes unmaintainable fast. That’s the actual motivation for copy-and-swap in the example after next: it gets the same strong guarantee with less code, by letting a unique_ptr or the compiler-generated cleanup do the work this try/catch is doing by hand.
Nothrow moves with noexcept
class Widget {
public:
void swap(Widget& other) noexcept {
std::swap(data, other.data);
}
Widget(Widget&& other) noexcept
: data(other.data) {
other.data = nullptr;
}
private:
int* data;
};
Marking the move constructor noexcept here is not just documentation — it changes what the standard library does with your type. std::vector, when it needs to grow and relocate its elements, checks whether the element type’s move constructor is noexcept. If it is, the vector moves elements into the new buffer; if it isn’t, the vector falls back to copying them instead, specifically so that a vector<Widget> resize can still offer the strong guarantee — if a copy throws partway through, the original buffer is left intact, whereas a partially-completed move would leave the vector in an inconsistent state with no way to roll back. Forgetting noexcept on a move constructor doesn’t just miss a minor optimization; it silently downgrades every std::vector of your type back to copy-based growth.
copy-and-swap for the strong guarantee
class Buffer {
int* data;
size_t size;
public:
Buffer& operator=(const Buffer& other) {
Buffer temp(other); // copy; may throw
swap(temp); // nothrow
return *this;
}
void swap(Buffer& other) noexcept {
std::swap(data, other.data);
std::swap(size, other.size);
}
};
Walk through why this gives the strong guarantee for free: Buffer temp(other); does all the risky work — allocating memory, copying elements — entirely on a temporary object that *this hasn’t touched yet. If that copy throws, *this is completely unaffected, because we never got past constructing temp. Only once the copy has fully succeeded does swap(temp) run, and swap is noexcept — just pointer and size exchanges, nothing that can fail. So the operation either completes entirely (copy succeeds, swap happens) or has no effect at all (copy throws, we never reach the swap) — which is the literal definition of the strong guarantee. This is the pattern I reach for by default for any assignment operator on a resource-owning class; writing the equivalent by hand with manual try/catch blocks, as in the manual-cleanup version above, is strictly more code for the same guarantee.
Using RAII
class Transaction {
public:
Transaction() {
begin();
}
~Transaction() {
if (!committed) {
rollback(); // rollback if stack unwinds before commit
}
}
void commit() {
// ...
committed = true;
}
private:
bool committed = false;
void begin() {}
void rollback() noexcept {}
};
This is the same idea database libraries and lock guards use, and once you’ve internalized it, exception safety stops being something you reason about line by line and starts being a property of the shape of your resource management: acquire in the constructor, release (or roll back) in the destructor, and let stack unwinding do the rest. The committed flag here is standard for transaction-like RAII objects — the destructor’s default behavior is “undo,” and calling commit() is what opts out of that default. If an exception fires anywhere between construction and the commit() call, the destructor runs during unwinding and rolls back automatically, with no explicit catch block anywhere in the calling code.
Partial updates, leaks, and throwing destructors
Half-applied updates
Example update implementations:
// ❌ Partial update, then exception
void update(Data& d) {
d.x = 10;
d.y = compute(); // if this throws, x changed but y/z did not
d.z = 30;
}
// ✅ Staging in a temporary, then assign
void update(Data& d) {
Data temp = d;
temp.x = 10;
temp.y = compute();
temp.z = 30;
d = temp; // strong guarantee if assignment is basic-safe and nothrow swap
}
I’ve hit this exact bug in production — a settings-update function that wrote three related fields in sequence, where the middle write validated user input and could throw. The result wasn’t a crash; it was a config object silently left half-updated, which only surfaced days later as a support ticket about a setting that “sometimes doesn’t stick.” The fix is the “stage in a copy, assign atomically at the end” pattern shown here, and the comment on the last line matters: this only gives the strong guarantee if the final assignment (d = temp) is itself basic-safe with a nothrow swap underneath it — which loops right back to copy-and-swap being the thing that actually makes the assignment safe.
Leaks between two raw allocations
// ❌ Manual ownership
void func() {
Resource* r1 = new Resource();
Resource* r2 = new Resource(); // if this throws, r1 leaks
delete r1;
delete r2;
}
// ✅ RAII
void func() {
auto r1 = std::make_unique<Resource>();
auto r2 = std::make_unique<Resource>();
}
The bug in the manual version is specifically about order and partial completion — r1 is fully constructed by the time new Resource() for r2 runs, so if that second allocation throws, r1 is a live, leaked pointer with nothing left to clean it up; the function’s stack frame is gone before delete r1 would ever execute. unique_ptr fixes this not by being smarter about exceptions, but by tying r1’s lifetime to a stack-allocated object whose destructor runs during unwinding regardless of how far execution got — the same mechanism as the Transaction example above, just applied to the more common case of plain heap allocation.
Destructors that throw
// ❌ Throwing destructor
class Bad {
public:
~Bad() {
throw std::runtime_error("error"); // undefined behavior if unwinding
}
};
// ✅ non-throwing destructor
class Good {
public:
~Good() noexcept {
try {
cleanup();
} catch (...) {
// swallow: destructor must not escape
}
}
};
The “undefined behavior” comment on Bad understates how bad this actually is: if ~Bad() throws while the stack is already unwinding because of a different exception, the C++ runtime has two simultaneous exceptions in flight with no way to decide which one propagates, and it calls std::terminate() immediately — no catch block anywhere in your program gets a chance to run. This is precisely why every destructor in the standard library is implicitly noexcept, and why writing noexcept explicitly on your own destructors (the default since C++11, but worth stating) is one of the few places in C++ where “just don’t let it throw, ever” is the entire rule, no exceptions (pun intended) for cleverness.
Four rules that make exception safety cheap
// 1. Prefer RAII
std::unique_ptr<Resource> resource;
// 2. Destructors should be noexcept
~MyClass() noexcept {}
// 3. swap should be noexcept
void swap(MyClass& other) noexcept {}
// 4. Move operations should be noexcept when possible
MyClass(MyClass&&) noexcept {}
These four rules read like a checklist, but they’re really one idea applied four times: keep the operations that can’t be allowed to fail (destruction, swapping, moving) as simple and allocation-free as possible, so the compiler’s noexcept promise is actually true rather than a lie that crashes the program via std::terminate the first time reality disagrees. In practice, rule 4 is the one worth double-checking on any class with a std::string, std::vector, or other owning member — those types’ move constructors are themselves noexcept, so a hand-written move constructor built only from moves of such members can safely be noexcept too, and should be.
FAQ
Q1: What are the exception-safety levels?
A:
- Basic: No leaks; object stays valid (invariants hold).
- Strong: All-or-nothing effect on observable state (often via copy-and-swap).
- Nothrow: No exceptions escape (often
noexcept).
Q2: What role does RAII play?
A: Automatic, scope-based resource management—it is the main tool for basic guarantee and leak freedom.
Q3: May destructors throw?
A: No. Destructors should not throw; use noexcept and contain errors.
Q4: How do I get the strong guarantee?
A: Common pattern: copy-and-swap for assignment-like operations.
Q5: Performance?
A: RAII wrappers like unique_ptr are typically no overhead vs correct manual delete. Throwing has cost on the error path; the non-throwing path is still “pay for what you use.”
Q6: Where can I read more?
A:
- Effective C++
- C++ Coding Standards
- Exception Safety in C++ (Stroustrup / Sutter-era guidance)
Related Articles
- C++ Exception Performance
- C++
noexceptSpecifier - C++
shared_ptrvsunique_ptr - C++
mallocvsnewvsmake_unique - C++ Copy/Move Constructors
- C++ Custom Deleters
- Lvalues, Rvalues, xvalues
- The State Pattern in C++
- C++ VTable Explained