C++ Exception Performance: Zero-Cost, noexcept, and Error

Key takeaways

C++ exception model: zero-cost on success path, cost of throw and unwind, noexcept and vector moves, frequent errors vs exceptions, and -fno-exceptions.

Introduction

C++ uses a zero-cost exception model on the success path: when no exception is thrown, cost is usually tiny. When an exception is thrown, stack unwinding is expensive. This article surveys behavior and tuning.


Zero-cost exception model

Success path vs throw path

#include <iostream>
#include <chrono>
#include <stdexcept>
void normalPath() {
    try {
        int x = 42;
        int y = x * 2;
    } catch (...) {
    }
}
void exceptionPath() {
    try {
        throw std::runtime_error("error");
    } catch (...) {
    }
}

Typical measurements show the throw path orders of magnitude slower per iteration than straight-line code—benchmarks depend on compiler, OS, and depth.

Idea behind “zero-cost”

// Compiler / runtime model (simplified):
// 1. Success path: minimal extra code on hot path
// 2. Exception tables: metadata elsewhere
// 3. On throw: table lookup + unwind + dtors

“Zero-cost” is a slightly misleading name for table-based exception handling, used by GCC and Clang on all common platforms (the Itanium C++ ABI) and by MSVC on x64. The compiler emits no instructions on the normal path to register try blocks or objects needing destruction. Instead it writes unwind tables (.eh_frame and the language-specific data area on ELF systems) that map every instruction address range to “what to destroy and where the handlers are”. Those tables sit in a separate section of the binary and are only read when something throws. The older model, still used by 32-bit MSVC x86 code, pushed a registration record on every function entry with a try or destructors, which cost time on every call even when nothing was thrown.

The name hides two real costs. Binary size grows — unwind tables are commonly a noticeable share of an executable — which matters on embedded targets. And the optimizer is constrained: any call that may throw is a potential exit from the function, so the compiler must keep objects in a destructible state at every such point, which can block some reorderings and inlining decisions. Neither shows up as a per-call cost, which is why microbenchmarks of normalPath report essentially nothing, but they are part of why some codebases (games, embedded, Google’s C++ style) build with -fno-exceptions.


Cost breakdown

What a throw does

class Resource {
public:
    Resource() { std::cout << "ctor" << std::endl; }
    ~Resource() { std::cout << "dtor" << std::endl; }
};
void func3() {
    Resource r3;
    throw std::runtime_error("error");
}
void func2() {
    Resource r2;
    func3();
}
void func1() {
    Resource r1;
    func2();
}
int main() {
    try {
        func1();
    } catch (const std::exception& e) {
        std::cout << "caught: " << e.what() << std::endl;
    }
}

Costs include exception object construction, table lookup, unwinding frames, and calling destructors along the way.

The output is ctor ctor ctor dtor dtor dtor caught: error: the three Resources are destroyed in reverse order (r3, r2, r1) before the handler in main runs. Concretely, a throw with the Itanium ABI does the following. __cxa_allocate_exception allocates the exception object on the heap (with an emergency pool for out-of-memory situations). The unwinder then runs in two phases: a search phase walks up the stack, looking up each return address in the unwind tables to find a catch whose type matches — which involves RTTI comparisons — without changing anything; then a cleanup phase walks the same frames again, running destructors, and finally transfers control to the handler. If the search phase finds no handler, std::terminate is called, and whether the stack was unwound first is implementation-defined, which is why an uncaught exception’s core dump sometimes still shows the throwing frame.

Each step involves binary searches over tables and indirect calls, so a single throw typically costs on the order of microseconds — thousands of times a normal function return. Historically there was also a scalability problem: the unwinder looked up tables through a global lock (dl_iterate_phdr on glibc), so many threads throwing at once serialized on it. GCC 13’s libgcc removed most of that contention, but on older toolchains a service that throws per request can see throughput collapse under load for this reason alone.

Deeper stacks cost more

Throwing through many frames is slower than shallow throws—keep error handling high-level when possible.


Error codes vs exceptions

Shape of APIs

#include <optional>
std::optional<int> divideErrorCode(int a, int b) {
    if (b == 0) {
        return std::nullopt;
    }
    return a / b;
}
int divideException(int a, int b) {
    if (b == 0) {
        throw std::invalid_argument("divide by zero");
    }
    return a / b;
}

On the success path, well-optimized code with or without try can be similar; on the error path, exceptions are usually far more expensive than returning nullopt or a sentinel.

The trade-off is not only speed. Return-value error handling makes every caller deal with the failure explicitly, which is safer for failures that callers can handle locally but noisy when an error has to travel through ten layers to reach the one that can do something about it. Error codes can also simply be ignored; marking the function [[nodiscard]] turns an ignored std::optional or error code into a compiler warning. std::optional carries no reason for the failure, which is why C++23’s std::expected<T, E> (or tl::expected before that) is the better return type when the caller needs to know why — it has the same success-path cost and still no unwinding.

A subtle point in favor of exceptions is that they cannot be lost: a constructor has no return value, so an object that fails to establish its invariants can only signal it by throwing (or by an extra “is valid” state that every method must check). That is the main reason the standard library itself uses exceptions.


noexcept optimization

noexcept and containers

class Widget {
    int* data;
public:
    Widget() : data(new int(42)) {}
    Widget(const Widget& other) : data(new int(*other.data)) {}  // deep copy

    // Version A — potentially-throwing move: vector copies on growth
    // Widget(Widget&& other) {
    //     data = other.data;
    //     other.data = nullptr;
    // }

    // Version B — noexcept move: vector can move elements
    Widget(Widget&& other) noexcept {
        data = other.data;
        other.data = nullptr;
    }

    ~Widget() { delete data; }
};

When std::vector<Widget> grows, it allocates a new buffer and must transfer the existing elements. The standard requires push_back to keep the strong exception guarantee: if something throws, the vector is left unchanged. With moves that can throw, that is impossible — if the fifth move throws, the first four elements have already been moved from and the original buffer is damaged. So the library uses std::move_if_noexcept: it moves if the move constructor is noexcept, and otherwise copies (leaving the originals intact until everything succeeded). With Version A, every reallocation deep-copies every Widget; with Version B, it moves pointers.

The copy constructor matters for this demonstration. If a type is move-only (no copy constructor), the vector moves even when the move is not noexcept, because it has no alternative, and then only offers the basic guarantee. Two common ways to lose noexcept without noticing: declaring any custom move constructor without the keyword, and having a member whose move constructor is not noexcept (then the implicitly generated move is not noexcept either). static_assert(std::is_nothrow_move_constructible_v<Widget>); next to the class definition catches both at compile time.


Common pitfalls

Pitfall 1: Exceptions for control flow

Prefer normal branches (if, break, return) over throw for frequent control transfer.

Pitfall 2: Frequent throws

Parsing or validation in a tight loop should usually return optional, expected, or error codes—not throw on every bad token.

Pitfall 3: Huge exception objects

Keep exception types small; avoid embedding giant buffers in the exception object.

Pitfall 4: -fno-exceptions

Embedded or extreme environments may disable exceptions entirely—use optional, expected, or error codes consistently.


Optimization strategies

Use noexcept where true

class Buffer {
    std::vector<int> data;
public:
    Buffer(Buffer&& other) noexcept 
        : data(std::move(other.data)) {}
    
    void swap(Buffer& other) noexcept {
        data.swap(other.data);
    }
    
    ~Buffer() noexcept = default;
};

Minimize throws

Reserve exceptions for exceptional paths (I/O open failure, invariant violation). Use error codes for expected failures (bad user input line).

Document contracts

Mark noexcept on functions that truly cannot throw; use conditional noexcept in templates.

noexcept is a promise the compiler does not verify. If an exception escapes a noexcept function at runtime, the program calls std::terminate immediately — no further unwinding, no chance for callers to catch it. That makes a wrong noexcept far worse than a missing one: a function that “never throws” until someone adds a std::string concatenation (which can throw std::bad_alloc) or a call into a library that throws on error now kills the process. Mark as noexcept the operations where it changes behavior — move constructors, move assignment, swap, destructors (implicitly noexcept already) — and leaf functions whose implementation genuinely cannot fail, and leave ordinary functions unmarked. The performance gain from noexcept on arbitrary functions is usually too small to measure.


Example: file processing

#include <fstream>
#include <string>
#include <optional>
#include <stdexcept>
#include <vector>
#include <iostream>
class FileProcessor {
public:
    std::vector<std::string> readLines(const std::string& filename) {
        std::ifstream file(filename);
        if (!file) {
            throw std::runtime_error("open failed: " + filename);
        }
        std::vector<std::string> lines;
        std::string line;
        while (std::getline(file, line)) {
            lines.push_back(line);
        }
        return lines;
    }
    
    std::optional<int> parseLine(const std::string& line) noexcept {
        try {
            return std::stoi(line);
        } catch (...) {
            return std::nullopt;
        }
    }
    
    std::vector<int> processFile(const std::string& filename) {
        auto lines = readLines(filename);
        std::vector<int> numbers;
        for (const auto& line : lines) {
            if (auto num = parseLine(line)) {
                numbers.push_back(*num);
            }
        }
        return numbers;
    }
};

The split follows the table below: a missing file is rare and the caller probably cannot continue, so readLines throws; a bad line is expected in real input, so parseLine returns std::nullopt. The example has a weakness that illustrates the whole article, though: parseLine is implemented with an exception — std::stoi throws std::invalid_argument for non-numeric text and std::out_of_range for overflow — and catches it immediately. In a file where many lines are invalid, that is one throw per bad line, which is exactly the “frequent throws” pitfall. std::stoi also silently accepts trailing garbage: "12abc" parses as 12.

std::from_chars (C++17, <charconv>) avoids both problems. It never throws and never allocates, and it reports how far it parsed, so you can reject trailing characters:

#include <charconv>
#include <string_view>

std::optional<int> parseLine(std::string_view line) noexcept {
    int value{};
    auto [ptr, ec] = std::from_chars(line.data(), line.data() + line.size(), value);
    if (ec != std::errc{} || ptr != line.data() + line.size()) {
        return std::nullopt;   // not a number, out of range, or trailing text
    }
    return value;
}

In profiles of parsing code, replacing a throw-and-catch parser like the original with from_chars is one of the few exception-related changes that reliably shows a large difference on bad input — and none on good input, which is the zero-cost model working as intended.


Choosing exceptions or error codes per failure

SituationTypical choiceReason
File missingExceptionRare in steady state
Network downException / typed errorOften rare
Parse failure per lineError code / optionalMay be frequent
Input validationError codeFrequent
OOM (operator new)Exception (unless no-except build)Rare, severe
Out-of-range programmer bugException / assertNot a hot path

The deciding factor is how often the failure happens in normal operation, not how serious it is. The success path of an exception costs almost nothing, and the cost of a throw is paid only when it happens, so an exception is cheap for a failure that occurs once per run and expensive for one that occurs once per record. The same function can fall on either side depending on the caller: parsing a configuration file that must be valid can throw, while parsing untrusted input line by line should return std::optional, an error code, or std::expected (C++23). When in doubt, measure the throw rate on real input rather than guessing.



Frequently Asked Questions (FAQ)

Q. Why does marking my move constructor noexcept make std::vector faster?

A. When a vector reallocates, it has to keep the strong exception guarantee, so it moves existing elements into the new buffer only if the move constructor cannot throw (it uses std::move_if_noexcept). If the move constructor is not noexcept and the type is copyable, vector copies every element instead, which is much more expensive for types that own heap memory. Declare the move constructor noexcept, or let the compiler generate it when all members are nothrow-movable, so reallocation can use moves.