Senior 5 min · March 06, 2026

Exception Handling in C++ — try, catch, throw and Real-World Patterns

Exception handling in C++ explained with real-world patterns, RAII, stack unwinding, and exception safety.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • C++ exceptions separate error handling from normal logic using try, throw, catch
  • Stack unwinding destroys local objects in reverse order, enabling RAII safety
  • Always catch by const reference to avoid slicing; throw by value
  • Zero-cost model: no overhead on the happy path, only when an exception is thrown
  • In production, uncaught exceptions call std::terminate() — always have a top-level catch-all
  • Biggest mistake: throwing inside a destructor, which causes immediate termination during stack unwinding
Plain-English First

Imagine you're baking a cake and the recipe says 'add eggs' — but you open the fridge and there are none. You don't just freeze and stare at the wall; you handle the situation: maybe you go buy eggs, maybe you make a different cake, or maybe you tell whoever asked for the cake that it can't be done today. Exception handling in C++ works exactly the same way. Your program tries to do something, something unexpected goes wrong, and instead of crashing silently or doing something dangerous, it raises its hand (throws an exception), someone catches that raised hand, and a decision is made about what to do next.

Software breaks. Not because developers are careless — but because the real world is unpredictable. A file your program needs gets deleted by the user. A network call times out at 2 AM. A user types their age as 'banana'. These aren't bugs in your logic; they're edge cases that exist at the boundary between your code and reality. Without a structured way to handle them, your program either crashes loudly with a segfault, or worse, silently produces wrong results. Neither is acceptable in production software.

C++ gives you exception handling — a formal, structured mechanism to separate the 'happy path' code from the 'something went wrong' code. Before exceptions existed, C programs (and early C++) handled errors by returning special integer codes like -1 or NULL. Every caller had to manually check those return values, and if even one layer forgot, the error disappeared silently up the call stack. Exceptions changed that: an error thrown deep in a library function can't be silently ignored — it will unwind the call stack until something explicitly catches it, or terminate the program making the problem impossible to miss.

By the end of this article you'll understand not just the syntax of try, catch, and throw, but WHY they work the way they do, how stack unwinding protects your resources, how to design your own exception hierarchy for real projects, and the exact mistakes that trip up even experienced C++ developers. You'll be able to write exception-safe code and explain the design choices behind it in an interview.

The Core Mechanic — try, throw, and catch in Plain English

The three keywords work as a team. You wrap risky code in a try block — code that might fail. If something goes wrong inside that block, you throw an exception object. Control immediately jumps out of the try block and into the matching catch block. Nothing between the throw site and the catch runs.

This separation is the whole point. Your normal logic stays clean inside the try block. Your error-handling logic lives in catch. They never tangle together with nested if-checks.

The thing you throw can be almost anything — an integer, a string, or (the right approach) an object from a class. In practice, you should always throw objects, not primitives, because objects can carry rich context: a message, an error code, the filename where the error happened. Throwing an int like -1 leaves the catcher with no context about what went wrong.

Here's a concrete example: you're parsing user input. A valid parse produces an int. A failure produces a typed exception that carries the bad input string. The catch block can then include that string in a user-friendly error message.

BasicExceptionDemo.cppCPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>
#include <stdexcept>
#include <string>

namespace io::thecodeforge::exceptions {

int parseAge(const std::string& rawInput) {
    // std::stoi throws invalid_argument or out_of_range internally
    int age = std::stoi(rawInput);

    if (age < 0 || age > 150) {
        throw std::out_of_range("Age must be 0-150, got: " + rawInput);
    }
    return age;
}

}

int main() {
    try {
        int age = io::thecodeforge::exceptions::parseAge("300");
        std::cout << "Age: " << age << "\n";
    } catch (const std::invalid_argument& e) {
        std::cerr << "Format Error: " << e.what() << "\n";
    } catch (const std::out_of_range& e) {
        std::cerr << "Range Error: " << e.what() << "\n";
    } catch (const std::exception& e) {
        std::cerr << "Generic Error: " << e.what() << "\n";
    }
    return 0;
}
Output
Range Error: Age must be 0-150, got: 300
Watch Out: catch order matters
Always list more specific exception types BEFORE more general ones. If you put catch(std::exception&) first, it swallows every derived exception type and your specific catch blocks below it will never execute.
Production Insight
In production, a misplaced catch order can silently suppress critical errors.
A generic catch(std::exception) before a domain-specific exception like std::bad_alloc will swallow OOM errors and leave your system in an undefined state.
Rule: always list derived types first, base class last.
Key Takeaway
throw by value, catch by const reference.
Catch order is specific → general.
Never throw inside a destructor or a noexcept function.
When to catch specific vs. generic?
IfYou need to take different actions per error type
UseCatch specific types in order from most to least derived
IfYou only need to log and rethrow or abort
UseCatch by const std::exception& at the top level
IfYou expect no exceptions but suspect a bug
UseUse catch(...) at a boundary to crash with diagnostic

Stack Unwinding — How C++ Cleans Up After an Exception

When an exception is thrown, C++ systematically destroys every local object between the throw site and the catch block, in reverse order of construction. This process is called stack unwinding.

This is the foundation of RAII (Resource Acquisition Is Initialization). If you use stack-allocated RAII wrappers like std::unique_ptr or std::lock_guard, their destructors run during unwinding even if an exception interrupts your function. This prevents memory leaks and stale locks without requiring manual cleanup code in every catch block.

The key detail: unwinding only applies to fully constructed objects. If an exception is thrown inside a constructor, only subobjects that have completed construction are destroyed. This is why you should never allocate resources with raw pointers in a constructor — use RAII wrappers that handle cleanup automatically.

StackUnwindingDemo.cppCPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <iostream>
#include <memory>
#include <stdexcept>
#include <string>

namespace io::thecodeforge::resources {

class DatabaseConnection {
public:
    explicit DatabaseConnection(const std::string& host) : host_(host) {
        std::cout << "[DB] Connected to " << host_ << "\n";
    }
    ~DatabaseConnection() {
        std::cout << "[DB] Connection to " << host_ << " closed cleanly.\n";
    }
    void query(const std::string& sql) {
        if (sql.find("DROP") != std::string::npos) {
            throw std::runtime_error("Forbidden query: " + sql);
        }
    }
private:
    std::string host_;
};

void runTask() {
    auto db = std::make_unique<DatabaseConnection>("prod-db-01");
    db->query("DROP TABLE users"); // Throws
}

}

int main() {
    try {
        io::thecodeforge::resources::runTask();
    } catch (const std::exception& e) {
        std::cerr << "Caught: " << e.what() << "\n";
    }
    return 0;
}
Output
[DB] Connected to prod-db-01
[DB] Connection to prod-db-01 closed cleanly.
Caught: Forbidden query: DROP TABLE users
Pro Tip: RAII is your safety net
Notice the destructor ran BEFORE the catch block. Never manage a raw resource without an RAII wrapper. If you find yourself writing 'delete' or 'fclose' manually, you are likely writing exception-unsafe code.
Production Insight
If a destructor throws during unwinding, you get double-fault → std::terminate.
This is why all destructors must be noexcept.
In production, audit third-party library destructors for potential throws.
Key Takeaway
Stack unwinding guarantees destructor call for fully constructed objects.
RAII + noexcept destructors = exception-safe code.
Test your constructors: if they throw partway, no leaked resources.

Exception Safety Guarantees — The Three Levels

When you write a function that may throw, you must decide what guarantees you provide to the caller. C++ defines three levels:

  1. Basic guarantee: The function will not leak resources or leave invariants broken. After an exception, the object is in a consistent (but possibly unspecified) state.
  2. Strong guarantee: If an exception is thrown, the program state is rolled back to exactly what it was before the call. The operation either fully succeeds or has no effect — think transactional.
  3. No-throw guarantee: The function never throws. This is the strictest. Destructors, swap, and move operations should always offer this.

Achieving these guarantees requires careful use of RAII, copy-and-swap idiom, and separating resource acquisition from side effects. In production, aim for strong guarantee on all public API functions, but measure the cost — making everything transactional may require deep copies or extra allocations.

StrongGuaranteeExample.cppCPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <vector>
#include <stdexcept>
#include <algorithm>

namespace io::thecodeforge::guarantees {

class DataProcessor {
    std::vector<int> data_;
public:
    // Strong guarantee via copy-and-swap
    void addValues(const std::vector<int>& newValues) {
        auto copy = data_;                    // Copy current state
        copy.insert(copy.end(), newValues.begin(), newValues.end()); // Modify copy
        // If insert throws, copy is destroyed, original data_ untouched
        std::swap(copy, data_);               // No-throw swap
    }
};

}
Think Like a Transaction
  • The strong guarantee = atomic commit: either everything or nothing.
  • Copy-and-swap is the go-to pattern for strong guarantee on mutable classes.
  • The no-throw guarantee is for destructors, swap, and move operations.
  • The basic guarantee is the minimum for all functions; you must at least not leak.
Production Insight
Strong guarantee adds cost — deep copy may be expensive for large containers.
In hot paths, sometimes the basic guarantee is acceptable if you log the error and reset.
Measure: if your strong guarantee allocation doubles memory usage, consider a mixed approach.
Key Takeaway
Basic: no leaks, valid state. Strong: all-or-nothing rollback. No-throw: never throws.
Copy-and-swap delivers strong guarantee with no-throw swap.
Destructors MUST offer no-throw guarantee.

Designing Custom Exception Hierarchies for Production

Throwing built-in std::runtime_error everywhere is better than throwing ints, but it won't scale. For a large codebase you need a domain-specific exception hierarchy rooted at std::exception. This lets callers catch by category (e.g., NetworkError, DatabaseError, ValidationError) and provides rich context.

Key design rules
  • Derive from std::runtime_error or std::logic_error, not directly from std::exception. The base class has no meaningful what().
  • Keep the hierarchy shallow — two or three levels deep is enough. Too deep and catching becomes difficult.
  • Each exception should carry as much context as possible: error code, operation name, relevant IDs, and a human-readable message.
  • Make exception classes non-copyable? No — they must be copyable because they are thrown by value. But avoid deep copies; store strings, not complex objects.
  • Mark exception constructors as noexcept to prevent a second exception during throw.

A well-designed hierarchy turns an opaque stack trace into a structured error that can be logged, retried, or transformed into an HTTP response.

CustomExceptionHierarchy.cppCPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <stdexcept>
#include <string>
#include <system_error>
#include <cstdint>

namespace io::thecodeforge::exceptions {

// Base for all domain exceptions
class AppException : public std::runtime_error {
public:
    explicit AppException(const std::string& message)
        : std::runtime_error(message) {}
};

// Network layer errors
class NetworkException : public AppException {
public:
    NetworkException(const std::string& message, int socketError)
        : AppException(message + " (errno: " + std::to_string(socketError) + ")"),
          socketError_(socketError) {}
    int socketError() const { return socketError_; }
private:
    int socketError_;
};

// Database specific errors
class DatabaseException : public AppException {
public:
    DatabaseException(const std::string& message, int64_t queryId)
        : AppException(message),
          queryId_(queryId) {}
    int64_t queryId() const { return queryId_; }
private:
    int64_t queryId_;
};

}
Don't over-engineer the hierarchy
You don't need 20 exception classes. Start with 3-5 base categories and add specific subclasses only when a catch site needs to differentiate behaviour. Over-abstraction makes the system harder to reason about.
Production Insight
A deep exception hierarchy with many base classes can bloat binary size and slow exception propagation (due to more type info).
In production, keep it shallow. Prefer adding context fields over subclassing.
Rule: If you can't think of a catch site that would distinguish them, don't create a new class.
Key Takeaway
Root all custom exceptions in std::runtime_error.
Keep hierarchy shallow, add context fields.
Mark exception constructors noexcept.
Deciding when to add a new exception class
IfA caller needs to catch this error type differently from others
UseAdd a new class deriving from AppException
IfThe error is a variant of an existing category (e.g., different HTTP status)
UseAdd a context field (e.g., statusCode) to the existing exception class
IfThe error is internal and never expected to be caught outside the module
UseKeep it as std::runtime_error or even assert-based termination

noexcept, Move Semantics, and Performance

The noexcept specifier tells the compiler that a function will never throw. This is not just documentation — it enables critical optimisations, especially in move operations. std::vector uses std::move_if_noexcept to decide whether to move elements during reallocation: if the move constructor is noexcept, elements are moved; otherwise they are copied. Copying can be orders of magnitude slower.

Similarly, standard library containers and algorithms prefer noexcept swap and move. Marking your move operations as noexcept can dramatically improve performance when resizing containers.

But noexcept is a contract. If you lie and the function does throw, std::terminate is called. Only use noexcept on functions you are certain cannot throw (destructors, simple moves, swaps, and trivial accessors). For complex logic that might throw (file I/O, network calls), do not mark noexcept.

In production, audit any function that is marked noexcept but contains a throw (or calls a throwing function). The compiler does not warn you — it calls terminate silently.

NoexceptMoveOptimisation.cppCPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <vector>
#include <string>
#include <cassert>

namespace io::thecodeforge::perf {

struct HeavyResource {
    std::string data;
    HeavyResource() = default;
    HeavyResource(HeavyResource&& other) noexcept
        : data(std::move(other.data)) {}
    HeavyResource& operator=(HeavyResource&& other) noexcept {
        data = std::move(other.data);
        return *this;
    }
};

void demo() {
    std::vector<HeavyResource> vec;
    vec.reserve(1);         // Initial allocation
    HeavyResource res;
    res.data = std::string(1000, 'A');
    vec.push_back(std::move(res)); // Should use noexcept move
    // When vec grows (push_back triggers reallocation), noexcept move is used,
    // avoiding a deep copy of the 1000-char string.
}

}
Performance Impact
A non-noexcept move constructor can force std::vector to fall back to copying every element during reallocation, turning an O(n) resize into an O(n) * copy-time overhead. For large objects, this is measurable.
Production Insight
We once saw a 3x slowdown in a critical path because a custom move constructor was not marked noexcept.
The vector copy constructor was being called on every push_back beyond capacity.
A single noexcept annotation restored performance. Measure before and after.
Key Takeaway
Mark move constructors and move assignments noexcept.
noexcept enables optimal reallocation in standard containers.
Do not mark noexcept on functions that may legitimately throw.
● Production incidentPOST-MORTEMseverity: high

The Midnight Terminate: A Destructor That Threw

Symptom
Server process terminated abruptly with no catch block in the backtrace. The log showed only the terminate message, no preceding exception context.
Assumption
The team assumed all destructors were noexcept because they didn't see any explicit throws. They relied on standard library containers and assumed RAII was bulletproof.
Root cause
A custom DatabaseConnection destructor called rollback() which threw an exception if the transaction was already partially committed. The rollback function was called during stack unwinding from a different exception, causing a second exception — C++ calls std::terminate() immediately.
Fix
Marked all destructors noexcept. Changed the rollback logic to silently swallow the failure (since the connection is being destroyed anyway) and logged the condition instead of throwing.
Key lesson
  • Destructors must never throw — mark them noexcept, even if they internally might fail.
  • Any function called from a destructor (directly or indirectly) must also be noexcept or catch all exceptions.
  • Add a top-level catch(...) in main() to catch every exception and log it before termination.
Production debug guideSymptom → action guide for the most common exception-related production issues4 entries
Symptom · 01
Program terminates with 'terminate called without an active exception'
Fix
You have a destructor that threw during stack unwinding. Mark all destructors noexcept and audit every function they call.
Symptom · 02
Catch block doesn't intercept expected exception type
Fix
Check catch order — specific types must come before std::exception. If you catch by value, slicing occurs and the derived type is lost.
Symptom · 03
Memory leak when exception thrown in constructor
Fix
If the constructor has partially allocated resources using raw pointers, an exception prevents cleanup. Use RAII wrappers (unique_ptr, scoped_ptr) to ensure cleanup even on partial construction.
Symptom · 04
Exception travels through a noexcept function and program terminates
Fix
Identify which function is noexcept but throws internally. Remove noexcept or wrap the throw in a try-catch internally.
★ Quick Exception Debug Cheat SheetCommands and approaches to diagnose exception handling problems in C++ code
Unexpected termination with std::terminate
Immediate action
Set a breakpoint on std::terminate or install a std::set_terminate handler that prints a backtrace.
Commands
gdb -ex 'catch throw' -ex 'run' ./your_app
cat /proc/$(pgrep your_app)/stack (if still hanging)
Fix now
Compile with -fno-omit-frame-pointer -g and rerun under GDB. The backtrace will show the throw site.
Memory leak suspected from exception path+
Immediate action
Run with sanitizers to pinpoint the exact leak source.
Commands
g++ -fsanitize=leak -g your_app.cpp -o your_app && ./your_app
valgrind --leak-check=full ./your_app 2>&1 | grep -A5 'definitely lost'
Fix now
Replace raw new/delete with unique_ptr or shared_ptr. Check constructors that allocate before throw.
Object slicing on catch – derived type information lost+
Immediate action
Check catch block signature — must be catch(const SomeException& e), not catch(SomeException e).
Commands
grep -rn 'catch (.* e)' src/ | grep -v 'const.*&'
Review all catch blocks to ensure they catch by const reference.
Fix now
Change catch blocks to catch by const reference. This is a one-character fix (add &) but prevents hours of debugging.
Exception Handling vs. Return Codes
AspectReturn Codes (C-style)C++ Exceptions
Error propagationManual — every caller must check and forwardAutomatic — propagates up the call stack without caller involvement
Forgetting to handleSilent — missed check means error disappearsLoud — uncaught exception calls std::terminate(), crash is obvious
Error contextJust a number (e.g., -1, errno)Rich object with message, type, and extra fields
Performance on successMinimal branching costZero overhead (Zero-cost model) on modern compilers
Resource safetyManual cleanup required before each returnAutomatic via RAII and stack unwinding

Key takeaways

1
Always catch by const reference (const std::exception& e) and throw by value to prevent object slicing and memory leaks.
2
Stack unwinding ensures that RAII objects are cleaned up automatically, making resource management robust even during failures.
3
Exception hierarchies should be rooted in std::exception to ensure interoperability with standard library and third-party handlers.
4
Reserve exceptions for truly exceptional events. For logic that fails frequently in normal operation, consider std::optional or std::expected (C++23).
5
Destructors must be noexcept to prevent double-exception termination during stack unwinding.
6
Mark move constructors and move assignments as noexcept to enable fast reallocation in standard containers.

Common mistakes to avoid

5 patterns
×

Throwing inside a destructor

Symptom
Program crashes with std::terminate if the destructor is called during stack unwinding from another exception. No stack trace of the original error.
Fix
Ensure all destructors are noexcept. If a destructor must perform an operation that could fail (e.g., file flush), catch all exceptions inside the destructor and handle them (log, ignore) without throwing.
×

Catching exceptions by value instead of by const reference

Symptom
Exception slicing: the derived type information is lost, catching code receives a sliced base class object. Cannot extract context fields from derived exceptions.
Fix
Always use catch (const SpecificException& e). The ampersand prevents slicing and avoids a copy.
×

Using `throw new MyException()` (throwing by pointer)

Symptom
Memory leak if the catcher forgets to delete the pointer. The exception object is allocated but never freed if the catcher only handles references.
Fix
Always throw by value: throw MyException(args). The exception object is managed by the C++ runtime and automatically destroyed after the catch.
×

Using exceptions for normal control flow

Symptom
Dramatic performance degradation because the zero-cost model only applies on the happy path. Throwing an exception is expensive (memory allocation, stack walking).
Fix
Reserve exceptions for truly exceptional, infrequent failures. For expected conditions (e.g., end of file, no results), use return values, std::optional, or std::expected (C++23).
×

Not providing a top-level catch-all in main()

Symptom
Any uncaught exception immediately calls std::terminate() with no diagnostic. In production, this means a process crash without a chance to log or clean up.
Fix
Wrap the entire main content in a try-catch: catch (const std::exception& e) { log(e.what()); return 1; } catch (...) { log("Unknown exception"); return 1; }. At least log and exit gracefully.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is the 'Zero-cost' exception model and how does it impact the 'happ...
Q02SENIOR
Explain Object Slicing in the context of catch blocks. Why is `catch (co...
Q03SENIOR
Trace the Stack Unwinding process. What happens to local objects if an e...
Q04SENIOR
What are Exception Safety Guarantees? Describe the difference between th...
Q05SENIOR
How does the `noexcept` specifier assist the compiler in optimizing move...
Q01 of 05SENIOR

What is the 'Zero-cost' exception model and how does it impact the 'happy path' performance of a C++ application?

ANSWER
The zero-cost exception model means that if no exception is thrown, there is no runtime overhead for exception handling. The compiler generates tables (e.g., unwind tables) that are only consulted when an exception propagates. On the happy path, the CPU executes the normal code without extra branches. This is in contrast to older models that checked error flags after every operation. The trade-off is increased binary size (for the tables) and slower exception propagation when a throw does happen.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
What is the difference between std::runtime_error and std::logic_error in C++?
02
Can I throw any type in C++, like an int or a string?
03
Why shouldn't I use catch(...) everywhere?
04
What is 'Exception Slicing'?
05
Is it safe to throw an exception from a constructor?
06
How do I check if a function is noexcept at compile time?
🔥

That's C++ Basics. Mark it forged?

5 min read · try the examples if you haven't

Previous
References in C++
11 / 19 · C++ Basics
Next
File I/O in C++