Skip to content
C Codeloom
C++

C++ Exceptions vs Error Codes

Compare exceptions and error codes in C++ for handling failures — performance, ergonomics, and when each style fits real codebases.

·4 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • Compare exception-based and error-code-based error handling
  • Understand the runtime cost of throwing
  • Use std::expected and std::optional for typed errors
  • Pick a style that fits your codebase and team
  • Avoid mixing styles inconsistently

Prerequisites

  • Functions and references — see /blog/cpp-functions-and-references

Every non-trivial program must report failures. C++ gives you two main mechanisms: exceptions that unwind the stack, and return-value-based error codes. Each has trade-offs, and large codebases often pick one and stick with it. This article walks through both.

What and Why

Exceptions let a deep function signal failure without polluting every signature on the way up. A throw aborts normal flow until a matching catch is found, running destructors as the stack unwinds. Error codes, by contrast, return success or failure as a normal value. Callers branch on the result and propagate manually.

The reason to care is that this choice ripples through your whole API. Mixing styles inconsistently — some functions throw, others return codes — produces code that is hard to read and easy to break.

Mental Model

Think of exceptions as an out-of-band channel. Normal returns travel one path, exceptions travel another. Error codes squash both into a single path: every call site must inspect the result. Exceptions favor the happy path at the cost of hidden control flow. Error codes favor explicitness at the cost of verbosity.

Modern C++ adds a third option: returning a sum type like std::expected<T, E> or std::optional<T>. These keep the result in the normal return channel but make the failure case impossible to ignore at compile time.

Hands-on Example

Here is the same operation written three ways.

#include <expected>
#include <stdexcept>
#include <string>

// Exception style
int parseExc(const std::string& s) {
    if (s.empty()) throw std::invalid_argument("empty");
    return std::stoi(s);
}

// Error code style
bool parseCode(const std::string& s, int& out) {
    if (s.empty()) return false;
    out = std::stoi(s);
    return true;
}

// std::expected style (C++23)
std::expected<int, std::string> parseExp(const std::string& s) {
    if (s.empty()) return std::unexpected("empty");
    return std::stoi(s);
}
Exception:   call ──> [ok value] ──┐
                └─> throw ──> catch handler

Error code:  call ──> (bool, out) ──> if (!ok) handle

Expected:    call ──> expected<T,E> ──> .has_value() ? use : handle

All three model success vs failure differently.
Three error styles: out-of-band throw, in-band bool, in-band sum type.

The exception version reads cleanly when nested deeply. The error code version forces every caller to check. The std::expected version gives type-safe failures without the runtime cost of throwing.

Common Pitfalls

A frequent mistake is throwing from destructors. Destructors run during stack unwinding from another exception, and a second exception will call std::terminate. Always mark destructors noexcept (the default) and catch internally if needed.

Another pitfall is ignoring exception safety. If a function throws halfway through, partially mutated state must remain valid. Aim for the strong guarantee where possible — operations either fully succeed or have no effect. RAII makes this much easier.

With error codes, a common bug is forgetting to check the return value. Some teams require [[nodiscard]] on every fallible function to make this a compiler warning.

Mixing styles silently is worst of all. If half your library throws and the other half returns codes, callers will guess wrong.

Practical Tips

For performance-critical inner loops where failures are rare, exceptions are typically faster than checking error codes on every call — they cost nothing when not thrown. For hot paths where failures are routine, prefer std::expected or std::optional.

In embedded or freestanding code, exceptions are often disabled. There, error codes or std::expected are the only option.

Pick one style at the library boundary and document it. Inside a function, RAII for cleanup makes both styles safer.

Use exception types that derive from std::exception so generic handlers can log a message. Add custom error categories with std::error_code for richer failure information without throwing.

Wrap-up

Exceptions and error codes both work — what fails is mixing them haphazardly. Choose based on your domain: exceptions for libraries with rare failures and deep call stacks, error codes or std::expected when failure is part of normal flow. Then enforce the choice with [[nodiscard]], RAII, and consistent API design.