Exception Safety

Reviewed & published by Brayan K

By the end of this lesson you'll be able to throw and catch exceptions correctly, let RAII clean up automatically when something fails, and choose the right safety guarantee — basic, strong, or no-throw — for every function you write.

Part of the free C++ course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

💡 Real-World Analogy

Think of a function call as a person climbing a ladder, one rung per call. A throw is shouting "abort!" and sliding back down. As you pass each rung on the way down, you must put back anything you borrowed there — that automatic clean-up is stack unwinding, and the destructors are the "put it back" step. The three guarantees describe how tidy you leave the room when you bail out: basic = nothing dropped on the floor (no leaks); strong = the room looks exactly as it did before you entered (all-or-nothing); no-throw = you promise you will never need to abort at all.

1. Throw, Try, and Catch

An exception is an error object that travels up the call stack until something handles it. You throw it to signal "I can't continue", wrap risky code in a try block, and handle the problem in a catch. Standard error types live in <stdexcept>: std::runtime_error for problems found at run time and std::invalid_argument for bad inputs — both derive from std::exception, whose .what() method returns your message. Read this worked example, then run it.

#include <iostream>
#include <stdexcept>   // std::runtime_error, std::invalid_argument
using namespace std;

// Withdraw money — throw if the request is impossible.
double withdraw(double balance, double amount) {
    if (amount <= 0)
        // throw STOPS the function and hands an error object up the stack.
        throw invalid_argument("Amount must be positive");
    if (amount > balance)
        throw runtime_error("Insufficient funds");
    return balance - amount;   // normal (happy) path
}

int main() {
    double balance = 100.0;

    // try wraps code that MIGHT throw.
    try {
        balance = withdraw(balance, 30);   // ok -> balance becomes 70
        cout << "New balance: " << balance << endl;   // New balance: 70

        balance = withdraw(balance, 500);  // throws runtime_error
        cout << "This line never runs" << endl;        // skipped!
    }
    // catch by const reference to the BASE type catches any std exception.
    catch (const exception& e) {
        // e.what() returns the message you passed to the constructor.
        cout << "Caught: " << e.what() << endl;        // Caught: Insufficient funds
    }

    cout << "Program keeps running. Balance: " << balance << endl; // Balance: 70
    return 0;
}

// ✅ Expected output:
//    New balance: 70
//    Caught: Insufficient funds
//    Program keeps running. Balance: 70

Notice how the line after the failing call was skipped — a throw jumps straight to the matching catch. Now your turn: fill in the three blanks to write your own throw, try, and catch.

#include <iostream>
#include <stdexcept>
using namespace std;

int safeDivide(int a, int b) {
    // 🎯 YOUR TURN — fill in each ___ then press "Try it Yourself".

    // 1) If b is 0, throw a runtime_error with a message.
    if (b == 0)
        ___;                       // 👉 throw runtime_error("Cannot divide by zero")

    return a / b;
}

int main() {
    // 2) Wrap the risky call in a try block.
    ___ {                          // 👉 the keyword that starts a guarded block
        cout << "10 / 2 = " << safeDivide(10, 2) << endl;   // 5
        cout << "10 / 0 = " << safeDivide(10, 0) << endl;   // throws
    }
    // 3) Catch the error BY CONST REFERENCE (so nothing is sliced/copied).
    catch (const exception& ___) {  // 👉 name the caught object, e.g. e
        cout << "Error: " << e.what() << endl;
    }

    // ✅ Expected output:
    //    10 / 2 = 5
    //    Error: Cannot divide by zero
    return 0;
}

2. Stack Unwinding & Why RAII Wins

When a throw fires, C++ unwinds the stack: it walks back up through every function it was inside and destroys each local object along the way, running its destructor. This is the secret to exception safety — if your resources (memory, files, locks, connections) are owned by local objects, they are guaranteed to be released, even on the error path. That pattern is RAII (Resource Acquisition Is Initialisation): tie a resource's lifetime to an object's lifetime, and you never have to remember to clean up manually.

#include <iostream>
#include <memory>      // std::unique_ptr
#include <stdexcept>
using namespace std;

struct Connection {
    Connection()  { cout << "  Connection OPENED" << endl; }
    ~Connection() { cout << "  Connection CLOSED" << endl; } // runs no matter what
    void send()   { throw runtime_error("network dropped"); }
};

void doWork() {
    // RAII: the resource lives in a local object. When doWork() exits —
    // normally OR by a thrown exception — the destructor runs automatically.
    auto conn = make_unique<Connection>();   // OPENED
    conn->send();                            // throws -> unwinding begins
    cout << "  (unreachable)" << endl;       // skipped
}   // <- conn's destructor runs HERE during unwinding -> CLOSED (no leak!)

int main() {
    try {
        doWork();
    } catch (const exception& e) {
        cout << "Handled: " << e.what() << endl;
    }
    // Output order proves the connection was closed BEFORE the catch ran:
    //   Connection OPENED
    //   Connection CLOSED
    //   Handled: network dropped
    return 0;
}

// ✅ Expected output:
//      Connection OPENED
//      Connection CLOSED
//    Handled: network dropped

3. The Three Exception-Safety Guarantees

Every function offers one of three promises about what happens when it throws. The basic guarantee says: no leaks, the object stays usable, but its state may have partly changed. The strong guarantee says: all-or-nothing — if it throws, nothing changed. The no-throw guarantee says: this can never fail, so you mark it noexcept. This example shows all three on one class.

#include <iostream>
#include <vector>
#include <stdexcept>
using namespace std;

class Account {
    vector<string> history;   // a record of every action
public:
    // BASIC guarantee: no leaks, object stays valid — but state MAY change.
    void logBasic(const string& note) {
        if (note.empty())
            throw invalid_argument("empty note");
        history.push_back(note);  // if this throws, no leak; vector still valid
    }

    // STRONG guarantee: all-or-nothing. On throw, nothing changed at all.
    void logBatchStrong(const vector<string>& notes) {
        vector<string> copy = history;        // 1) work on a COPY
        for (const auto& n : notes) {
            if (n.empty())
                throw invalid_argument("empty note in batch"); // original safe
            copy.push_back(n);
        }
        swap(history, copy);                  // 2) commit — swap is noexcept
    }

    // NO-THROW guarantee: promises never to throw. (size() can't fail.)
    size_t count() const noexcept { return history.size(); }
};

int main() {
    Account acc;
    acc.logBasic("opened");
    try {
        acc.logBatchStrong({"deposit", "", "withdraw"}); // fails on the ""
    } catch (const exception& e) {
        cout << "Batch failed: " << e.what() << endl;
    }
    // Strong guarantee held: the failed batch added NOTHING. Count is still 1.
    cout << "Actions stored: " << acc.count() << endl;   // Actions stored: 1
    return 0;
}

// ✅ Expected output:
//    Batch failed: empty note in batch
//    Actions stored: 1

4. The Copy-and-Swap Idiom (Strong Guarantee)

The classic recipe for the strong guarantee is copy-and-swap: do all the risky work on a copy, and only when it has fully succeeded, swap the copy into place. Because std::swap on standard containers is noexcept (it just exchanges internal pointers), the commit step can't fail — so either everything works or your original is untouched. Your turn: complete the two blanks to make addAll strong.

#include <iostream>
#include <vector>
#include <stdexcept>
using namespace std;

class Playlist {
    vector<string> songs;
public:
    // Add several songs as ALL-OR-NOTHING (the strong guarantee).
    void addAll(const vector<string>& newSongs) {
        // 🎯 YOUR TURN — make this strong with copy-and-swap.

        // 1) Make a COPY of songs to do the risky work on.
        vector<string> temp = ___;     // 👉 copy the current songs vector

        for (const auto& s : newSongs) {
            if (s.empty())
                throw invalid_argument("blank song title");
            temp.push_back(s);
        }

        // 2) Commit only on success — swap can't throw, so it's the safe point.
        ___(songs, temp);              // 👉 the noexcept call that commits: swap
    }
    size_t size() const noexcept { return songs.size(); }
};

int main() {
    Playlist pl;
    pl.addAll({"Song A", "Song B"});
    try {
        pl.addAll({"Song C", "", "Song D"});  // throws on the ""
    } catch (const exception& e) {
        cout << "Add failed: " << e.what() << endl;
    }
    cout << "Songs: " << pl.size() << endl;   // strong guarantee -> still 2

    // ✅ Expected output:
    //    Add failed: blank song title
    //    Songs: 2
    return 0;
}

5. noexcept and Why It Matters

noexcept is a promise to the compiler that a function never throws. Break that promise and the program calls std::terminate immediately — so only mark things that truly can't fail (moves, swaps, simple getters). The biggest payoff is performance: std::vector will only move its elements when it grows if their move constructor is noexcept; otherwise it must copy them to preserve its own strong guarantee.

Common mistake: forgetting noexcept on a move constructor silently makes vector fall back to copying — a quiet performance killer for types with expensive copies.

#include <iostream>
#include <vector>
#include <utility>
using namespace std;

class Buffer {
    int* data;
    size_t sz;
public:
    Buffer(size_t n) : data(new int[n]{}), sz(n) {}
    ~Buffer() { delete[] data; }                 // RAII: frees memory

    // noexcept move ctor — vector PREFERS this when it grows. Without
    // noexcept the vector must COPY instead (to keep the strong guarantee).
    Buffer(Buffer&& other) noexcept
        : data(other.data), sz(other.sz) {
        other.data = nullptr;                    // steal, don't copy
        other.sz = 0;
        cout << "moved " << sz << " ints (cheap)" << endl;
    }
    Buffer(const Buffer&) = delete;              // no copying allowed
    size_t size() const noexcept { return sz; }
};

int main() {
    vector<Buffer> v;
    v.reserve(2);
    v.emplace_back(100);
    v.emplace_back(200);
    v.emplace_back(300);   // forces a regrow -> existing Buffers are MOVED
    cout << "stored " << v.size() << " buffers" << endl;  // stored 3 buffers
    return 0;
}

// ✅ Expected output:
//    moved 100 ints (cheap)
//    moved 200 ints (cheap)
//    stored 3 buffers

Common Errors (and the fix)

📋 Quick Reference — the three guarantees

GuaranteePromise on throwHow you provide it
BasicNo leaks; object still valid, but state may have changedRAII (own resources in objects)
StrongAll-or-nothing; state is exactly as before the callCopy-and-swap
No-throwNever throws at allnoexcept (moves, swap, getters)

Aim for the strongest guarantee you can afford. The strong guarantee costs a copy, so use it for critical operations and the basic guarantee on hot paths.

Mini-Challenge: a Strong-Guarantee Cart

No blanks this time — just a brief and an outline. Make addItems all-or-nothing with copy-and-swap and mark total as noexcept. Run it and confirm the rejected batch leaves the cart unchanged.

#include <iostream>
#include <vector>
#include <stdexcept>
using namespace std;

class Cart {
    vector<double> prices;
public:
    // 🎯 MINI-CHALLENGE: a strong-guarantee "checkout"
    // 1. addItems(items): give the STRONG guarantee using copy-and-swap.
    //      - copy prices into a temp vector
    //      - if any price is negative, throw invalid_argument("bad price")
    //      - otherwise push it onto the temp
    //      - swap(prices, temp) only at the very end
    // 2. total(): mark it noexcept and return the sum of prices.
    //
    // ✅ Expected: adding {9.99, -1, 5} throws, leaving the cart unchanged,
    //    so a cart that started empty still reports total 0.

    // your code here
};

int main() {
    Cart cart;
    try {
        cart.addItems({9.99, -1.0, 5.0});   // should throw, change nothing
    } catch (const exception& e) {
        cout << "Rejected: " << e.what() << endl;
    }
    // cout << "Total: " << cart.total() << endl;   // un-comment once written
    return 0;
}

Pro Tips

🎉 Lesson Complete

Practice quiz

Why should you catch exceptions by const reference (const std::exception&)?

  • It is faster to type
  • References cannot be caught any other way
  • Catching by value copies and slices off the derived part, losing the real type and message
  • It automatically rethrows the exception

Answer: Catching by value copies and slices off the derived part, losing the real type and message. Catching the base type by value slices the object; const reference keeps the full object and avoids the copy.

What is stack unwinding?

  • Walking back up the call stack on a throw, destroying every fully-constructed local object on the way
  • Reordering the call stack for speed
  • Clearing all global variables
  • Restarting main() from the top

Answer: Walking back up the call stack on a throw, destroying every fully-constructed local object on the way. On a throw, C++ unwinds the stack and runs destructors of locals — which is exactly how RAII frees resources.

What does the BASIC exception-safety guarantee promise?

  • Nothing changes at all if the operation throws
  • The function never throws
  • The program terminates cleanly
  • No leaks and the object stays valid, but its state may have partially changed

Answer: No leaks and the object stays valid, but its state may have partially changed. Basic guarantees no leaks and a usable object, but state may have changed; strong is the all-or-nothing one.

What does the STRONG guarantee promise on a throw?

  • No leaks, but partial changes are allowed
  • All-or-nothing: the object is exactly as it was before the call
  • The function is marked noexcept
  • Resources are leaked but the object survives

Answer: All-or-nothing: the object is exactly as it was before the call. The strong guarantee is all-or-nothing: if the operation throws, the object is unchanged.

Which idiom is the usual way to provide the strong guarantee?

  • Copy-and-swap
  • try-catch-rethrow
  • Reference counting
  • Double-checked locking

Answer: Copy-and-swap. Copy-and-swap does the risky work on a copy, then commits with a noexcept swap, giving all-or-nothing behaviour.

Why is the commit step (swap) the safe point in copy-and-swap?

  • swap is the fastest operation
  • swap deletes the old data immediately
  • std::swap on standard containers is noexcept, so the commit cannot fail
  • swap always succeeds because it copies everything

Answer: std::swap on standard containers is noexcept, so the commit cannot fail. Because swap just exchanges internal pointers and is noexcept, the commit can't throw — so either it all works or the original is untouched.

Should you ever throw from a destructor?

  • Yes, it is the cleanest way to report errors
  • No — destructors are noexcept by default, so an escaping exception calls std::terminate
  • Only in release builds
  • Only if the constructor also throws

Answer: No — destructors are noexcept by default, so an escaping exception calls std::terminate. Destructors are implicitly noexcept; an escaping exception crashes via std::terminate, so handle failable cleanup inside the destructor.

Why does marking a move constructor noexcept matter for performance?

  • It makes the move run on a separate thread
  • noexcept makes the constructor inline
  • It has no real effect
  • std::vector only moves elements on regrowth if the move is noexcept; otherwise it copies them

Answer: std::vector only moves elements on regrowth if the move is noexcept; otherwise it copies them. Without noexcept, vector falls back to copying to preserve its own strong guarantee — a quiet performance killer.

What makes RAII clean up resources automatically even when an exception is thrown?

  • The garbage collector
  • Stack unwinding runs the local object's destructor as it leaves scope
  • A try block around main()
  • The noexcept keyword

Answer: Stack unwinding runs the local object's destructor as it leaves scope. When the stack unwinds, each local's destructor runs — so resources owned by local objects are released with no manual cleanup.

What happens if a function is marked noexcept but actually throws?

  • The exception is silently ignored
  • It is automatically caught in main()
  • std::terminate is called immediately
  • The compiler refuses to build it

Answer: std::terminate is called immediately. Breaking the noexcept promise calls std::terminate, so only mark things that truly cannot fail.

Continue this course

Frequently asked questions

Why catch by const reference (const std::exception&) instead of by value?

Catching by value copies the exception, and if you catch the base type std::exception by value you slice off the derived part — losing the real message and type. Catching by const reference keeps the full object and avoids the copy, so it is the standard idiom.

What is stack unwinding?

When an exception is thrown, C++ walks back up the call stack looking for a matching catch, destroying every fully-constructed local object on the way. Those destructors are what free your resources — which is exactly why RAII makes code exception-safe automatically.

What is the difference between the basic and strong guarantee?

The basic guarantee promises no leaks and a valid object, but the state may have partially changed. The strong guarantee promises all-or-nothing: if the operation throws, the object is exactly as it was before the call. Copy-and-swap is the usual way to provide the strong guarantee.

Should I ever throw from a destructor?

No. Destructors are implicitly noexcept since C++11, so an exception escaping one calls std::terminate and crashes the program — and during stack unwinding a second exception is undefined behaviour. Do any failable cleanup inside the destructor in a try/catch and swallow it there.

Why does noexcept matter for performance?

std::vector only moves your objects when it reallocates if the move constructor is noexcept; otherwise it copies them to preserve the strong guarantee. Marking moves noexcept can turn an expensive copy of every element into a cheap pointer steal.

Related lessons