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.
20+ years shipping performance-critical C and C++ systems. Notes here come from systems that actually shipped.
- 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
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.
Why Exception Handling Is Not Optional in C++
Exception handling in C++ is a language mechanism for transferring control and information from a point where an error occurs (throw) to a handler (catch) designed to deal with that error type. The core mechanic is stack unwinding: when a throw executes, the runtime destructs all automatic objects between the throw site and the matching catch block, ensuring resource cleanup via RAII. This is not a performance path—throwing an exception can be 10,000x slower than a normal return—so exceptions are for exceptional, not routine, control flow.
In practice, you catch by reference (catch (std::exception const& e)) to avoid slicing and to preserve polymorphic behavior. The standard library defines a hierarchy rooted at std::exception, but you can throw any type—though throwing non-class types is a code smell. A function can declare noexcept, promising not to throw; violating that calls std::terminate immediately. The key property: exception safety guarantees (basic, strong, nothrow) let you reason about program state after an error, which is critical in systems where partial failure is unacceptable.
Use exceptions when an operation cannot fulfill its contract and the caller cannot reasonably be expected to check every error code—e.g., memory allocation failure, file I/O errors in a deep call chain, or invariant violations in a library. In real systems, exceptions are the backbone of error propagation in constructors (where return codes are impossible) and in multi-threaded code where exceptions can be captured via std::future. Avoid them in tight loops, embedded systems with hard real-time constraints, or when interfacing with C code that expects no stack unwinding.
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.
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.
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:
- 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.
- 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.
- 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.
- 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.
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.
- Derive from
std::runtime_errororstd::logic_error, not directly fromstd::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
noexceptto 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.
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.
noexcept annotation restored performance. Measure before and after.How Exceptions Actually Propagate — Don't Let Your Error Handling Get Swallowed
Exceptions don't just disappear after you throw them. They bubble up the call stack until something catches them—or your program crashes. This propagation is automatic, deterministic, and brutally unforgiving if you don't plan for it.
When you throw inside a function, C++ immediately unwinds the stack, destroying local objects in reverse order of construction. The runtime then searches for a matching catch block, starting from the throw point and moving outward through enclosing try blocks. If it finds one, control jumps there. If not, std::terminate() gets called and your process dies.
This isn't magic. It's a language guarantee you can rely on for cleanup (stack unwinding with RAII) and error reporting. But here's the production trap: if you catch a generic ... without rethrowing, you've just eaten an exception that might have been fatal. Always ask yourself: "Did I just silence a signal fire for a maintenance outage?"
Propagation works across library boundaries, threads (with std::exception_ptr), and even through noexcept functions—those just call terminate instead. Know your stack depth. Trace your throw paths. Your future self debugging at 3 AM will thank you.
catch (const std::exception& e)) to avoid object slicing. Catching by value copies the exception, losing polymorphic behavior from derived types.throw; re-throws the current exception without slicing; catch by reference always.Nested Try-Catch Blocks — When One Safety Net Isn't Enough
Real code isn't flat. You might be parsing a config file while managing a database connection pool. If the config parser throws, you don't want to lose the DB handle. That's where nested try-catch blocks earn their keep.
You can nest try blocks inside other try blocks, inside functions, inside loops—C++ doesn't care. Each level catches exceptions thrown within its scope. If an inner catch can't handle the error, it re-throws, and the outer block gets a crack at it. This lets you implement partial recovery: maybe you log and continue in the inner block, but abort the whole operation in the outer block.
But here's the hard truth: nested blocks make your control flow look like spaghetti if you overuse them. Use them deliberately, not defensively. A common pattern is to have an inner try-catch for resource cleanup (closing files, releasing mutexes) and an outer one for business logic errors. Keep them shallow—three levels deep is already a code smell.
Pro tip: Use RAII wrappers to avoid manual cleanup in inner catch blocks. A std::lock_guard or custom scope guard will clean itself up during stack unwinding, so you don't have to write the same cleanup code in every catch.
Catch Order Matters — Why Your catch(...) Is Probably Wrong
Every C++ developer learns catch blocks. Few learn that the compiler runs them in declaration order — and that order can silently eat your exceptions. If you place a base class handler before a derived class handler, the derived exception never fires. Your finely-tuned custom exceptions get swallowed by a generic std::exception catch. This isn't academic. I've debugged production outages caused by nothing more than rearranged catch blocks.
The fix is brutal but simple: list your most specific exception types first, work your way to std::exception, and only then use catch(...) as your last resort. The compiler won't warn you. No lint rule catches every case. This is one of those sharp edges where C++ trusts you to know what you're doing. Don't blow it.
Multiple catch Blocks — The Single Responsibility Rule of Error Handling
Your catch blocks are not a bucket. They are a decision tree with exactly one path executed. Each catch should handle one type of failure — and one type only. Mixing retry logic, logging, and state cleanup in the same block is asking for cascading bugs that make production incidents take hours to untangle.
The principle is simple: one exception type, one responsibility. A FileNotFoundError gets a different response than a PermissionDeniedError. If you're tempted to catch both with the same handler, you're hiding the real problem from yourself. Write separate catch blocks, log distinctly, and let your monitoring see the difference. This isn't about code golf. It's about being able to grep your logs at 3 AM and know exactly which path failed.
The Midnight Terminate: A Destructor That Threw
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.- 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.
gdb -ex 'catch throw' -ex 'run' ./your_appcat /proc/$(pgrep your_app)/stack (if still hanging)Key takeaways
const std::exception& e) and throw by value to prevent object slicing and memory leaks.std::exception to ensure interoperability with standard library and third-party handlers.std::optional or std::expected (C++23).noexcept to prevent double-exception termination during stack unwinding.noexcept to enable fast reallocation in standard containers.Common mistakes to avoid
5 patternsThrowing inside a destructor
Catching exceptions by value instead of by const reference
catch (const SpecificException& e). The ampersand prevents slicing and avoids a copy.Using `throw new MyException()` (throwing by pointer)
delete the pointer. The exception object is allocated but never freed if the catcher only handles references.throw MyException(args). The exception object is managed by the C++ runtime and automatically destroyed after the catch.Using exceptions for normal control flow
Not providing a top-level catch-all in main()
catch (const std::exception& e) { log(e.what()); return 1; } catch (...) { log("Unknown exception"); return 1; }. At least log and exit gracefully.Interview Questions on This Topic
What is the 'Zero-cost' exception model and how does it impact the 'happy path' performance of a C++ application?
Frequently Asked Questions
20+ years shipping performance-critical C and C++ systems. Notes here come from systems that actually shipped.
That's C++ Basics. Mark it forged?
9 min read · try the examples if you haven't