Skip to content

Exception Safety Guarantees

Learn the three levels of exception safety — basic, strong, and no-throw — and how RAII and copy-and-swap provide them.

The Three Guarantees

Exception safety is not about preventing exceptions — it is about defining what your code guarantees when an exception does occur. Every function you write implicitly provides one of these levels:

1. No-throw guarantee (strongest): The function never throws. It always succeeds. Examples: destructors, swap, simple getters. Marked noexcept.

2. Strong guarantee (commit-or-rollback): If the function throws, the program state is exactly as it was before the call. It is as if the function was never called. Think of a database transaction that either fully commits or fully rolls back.

3. Basic guarantee (minimum acceptable): If the function throws, the program is in a valid but unspecified state. No resources are leaked, all invariants hold, but the exact state may differ from what it was before.

There is also a fourth, unacceptable level: no guarantee — where an exception can leave the program in a corrupted state with leaked resources. Production code must never provide less than the basic guarantee.

RAII: The Foundation of Exception Safety

RAII (Resource Acquisition Is Initialization) binds a resource's lifetime to an object's lifetime. The constructor acquires the resource; the destructor releases it. Since destructors run during stack unwinding, RAII provides the basic guarantee automatically.

raii_db.cpp
#include <fstream>
#include <string>
#include <stdexcept>
#include <iostream>

class DatabaseConnection {
public:
    explicit DatabaseConnection(const std::string& conn_str)
        : conn_str_(conn_str), connected_(false) {
        // Simulate connection
        if (conn_str.empty()) {
            throw std::invalid_argument("empty connection string");
        }
        connected_ = true;
        std::cout << "Connected to: " << conn_str_ << '\n';
    }

    ~DatabaseConnection() {
        if (connected_) {
            // Cleanup never throws
            std::cout << "Disconnected from: " << conn_str_ << '\n';
            connected_ = false;
        }
    }

    void execute(const std::string& query) {
        if (!connected_) throw std::runtime_error("not connected");
        // Even if this function throws, the destructor
        // will still close the connection during unwinding
        std::cout << "Executing: " << query << '\n';
    }

    // Non-copyable, movable
    DatabaseConnection(const DatabaseConnection&) = delete;
    DatabaseConnection& operator=(const DatabaseConnection&) = delete;
    DatabaseConnection(DatabaseConnection&& other) noexcept
        : conn_str_(std::move(other.conn_str_)),
          connected_(other.connected_) {
        other.connected_ = false;
    }

private:
    std::string conn_str_;
    bool connected_;
};

void process_data() {
    DatabaseConnection db("postgres://localhost/mydb");
    db.execute("SELECT * FROM users");
    // If execute() throws, ~DatabaseConnection() still runs
    // — the connection is always properly closed
}

Copy-and-Swap for the Strong Guarantee

The copy-and-swap idiom provides the strong exception guarantee for assignment operators. The idea: make a copy first (which may throw), then swap with the copy (which never throws). If the copy throws, the original object is untouched.

copy_and_swap.cpp
#include <algorithm>
#include <utility>
#include <cstddef>

class Matrix {
public:
    Matrix(std::size_t rows, std::size_t cols)
        : rows_(rows), cols_(cols),
          data_(new double[rows * cols]{}) {}

    // Copy constructor — may throw (allocates memory)
    Matrix(const Matrix& other)
        : rows_(other.rows_), cols_(other.cols_),
          data_(new double[other.rows_ * other.cols_]) {
        std::copy(other.data_,
                  other.data_ + rows_ * cols_,
                  data_);
    }

    // Move constructor — never throws
    Matrix(Matrix&& other) noexcept
        : rows_(other.rows_), cols_(other.cols_),
          data_(other.data_) {
        other.data_ = nullptr;
        other.rows_ = other.cols_ = 0;
    }

    ~Matrix() { delete[] data_; }

    // No-throw swap — the key ingredient
    friend void swap(Matrix& a, Matrix& b) noexcept {
        using std::swap;
        swap(a.rows_, b.rows_);
        swap(a.cols_, b.cols_);
        swap(a.data_, b.data_);
    }

    // Copy-and-swap assignment: STRONG guarantee
    // Takes parameter by value (invokes copy/move ctor)
    Matrix& operator=(Matrix other) noexcept {
        swap(*this, other);
        return *this;
        // 'other' is destroyed here, freeing old data
    }

private:
    std::size_t rows_, cols_;
    double* data_;
};

noexcept and Move Operations

The STL relies heavily on noexcept for move constructors. Consider std::vector::push_back: when the vector must reallocate, it needs to transfer elements from the old buffer to the new one.

- If the element's move constructor is noexcept, the vector moves elements (fast).
- If the move constructor might throw, the vector copies elements instead (slow, but safe — if a copy throws, the original buffer is still intact).

This is why marking move constructors noexcept is not just good practice — it can be a 10x or 100x performance difference for types stored in containers.

You can check at compile time with std::is_nothrow_move_constructible_v. The STL uses this trait internally to decide its strategy.

Identifying the Guarantee Level

When writing a function, think about which guarantee it provides. Here is an example of all three levels in a single class.

guarantee_levels.cpp
#include <vector>
#include <string>
#include <algorithm>
#include <stdexcept>

class UserRegistry {
public:
    // NO-THROW: simple getter, can never fail
    std::size_t size() const noexcept {
        return users_.size();
    }

    // STRONG GUARANTEE: either the user is added or nothing changes
    void add_user(const std::string& name) {
        if (name.empty()) {
            throw std::invalid_argument("name cannot be empty");
        }
        // If push_back throws (e.g., bad_alloc during reallocation),
        // vector guarantees the original data is intact.
        users_.push_back(name);
    }

    // BASIC GUARANTEE: if an exception occurs partway through,
    // some users may have been processed and some may not.
    // But the object is still in a valid state (no leaks, no corruption).
    void normalize_all() {
        for (auto& user : users_) {
            // std::transform could theoretically throw
            // (e.g., if user string allocation fails during tolower)
            std::transform(user.begin(), user.end(), user.begin(),
                          [](unsigned char c) { return std::tolower(c); });
        }
    }

private:
    std::vector<std::string> users_;
};
Best Practice

Follow these guidelines to write exception-safe code consistently.

  • Use RAII everywhere: Never hold raw resources — wrap them in smart pointers or RAII wrappers
  • Mark move constructors and swap noexcept: This enables STL optimizations and provides the no-throw guarantee
  • Do all the work that can throw before modifying state: Allocate, compute, and validate first — then commit with no-throw operations like swap
  • Prefer the copy-and-swap idiom for assignment operators to get the strong guarantee with minimal effort
  • Destructors must never throw: They are implicitly noexcept in C++11 and later
Pitfall

These patterns break exception safety guarantees and lead to subtle bugs.

  • Modifying state before completing all throwing operations: If step 2 of 3 throws, step 1 already changed the object
  • Forgetting that new can throw std::bad_alloc: Every dynamic allocation is a potential throw point
  • Non-noexcept move constructors: The STL will silently fall back to copying, killing performance
  • Catching exceptions too broadly: catch(...) without rethrowing hides the real problem
Key Takeaways
  • The three guarantees are: no-throw (always succeeds), strong (commit-or-rollback), and basic (valid but unspecified state)
  • RAII is the foundation — destructors run during unwinding, ensuring resources are always freed
  • Copy-and-swap gives the strong guarantee: do all throwing work first, then swap (which is noexcept)
  • noexcept move constructors are critical — without them, std::vector falls back to copying on reallocation
  • Every function should aim for at least the basic guarantee; no-throw where possible

Quiz — Test Your Knowledge

(20 XP)

1. What does the strong exception guarantee promise?

2. Why does `std::vector` copy elements instead of moving them when the move constructor is not `noexcept`?

3. In the copy-and-swap idiom, why does the assignment operator take its parameter by value?