Exception Handling in C++ — try, catch, throw and Real-World Patterns
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.
The catch block specifies what type it handles. If the thrown type matches, that catch runs. If nothing matches, the exception keeps propagating up the call stack to the next enclosing try-catch. If it reaches the top of the stack without being caught, std::terminate() is called and your program ends — loudly, which is actually safer than silently swallowing the error.
#include <iostream> #include <stdexcept> // gives us std::runtime_error and friends #include <string> // Simulates reading a user's age from some input source // In a real app this might parse a config file or a network packet int parseAge(const std::string& rawInput) { // stoi throws std::invalid_argument if the string isn't a number // and std::out_of_range if the number is too large for an int int age = std::stoi(rawInput); // this is the risky operation // Business-logic validation — we throw our own error with a clear message if (age < 0 || age > 150) { throw std::out_of_range( "Age must be between 0 and 150, got: " + rawInput ); } return age; } int main() { // --- Test Case 1: valid input --- try { int validAge = parseAge("28"); std::cout << "Parsed age successfully: " << validAge << "\n"; } catch (const std::exception& error) { // We won't reach here for valid input std::cerr << "Error: " << error.what() << "\n"; } // --- Test Case 2: non-numeric input --- try { int badAge = parseAge("banana"); // stoi will throw invalid_argument std::cout << "This line never prints: " << badAge << "\n"; } catch (const std::invalid_argument& error) { // Catches specifically the 'not a number' case std::cerr << "[invalid_argument] " << error.what() << "\n"; } catch (const std::out_of_range& error) { // Catches the 'number out of valid range' case std::cerr << "[out_of_range] " << error.what() << "\n"; } // --- Test Case 3: logically invalid age --- try { int impossibleAge = parseAge("300"); // our business-rule throw std::cout << "This line never prints: " << impossibleAge << "\n"; } catch (const std::out_of_range& error) { std::cerr << "[out_of_range] " << error.what() << "\n"; } std::cout << "Program continues normally after all catch blocks.\n"; return 0; }
[invalid_argument] stoi: no conversion
[out_of_range] Age must be between 0 and 150, got: 300
Program continues normally after all catch blocks.
Stack Unwinding — How C++ Cleans Up After an Exception
Here's something that trips up almost every intermediate C++ developer: when an exception is thrown, C++ doesn't just jump to the catch block. It systematically destroys every local object between the throw site and the catch block, in reverse order of construction. This process is called stack unwinding.
Why does this matter? Because destructors run. If you have a file handle, a mutex lock, or a database connection as a local object, its destructor will be called during unwinding even if an exception interrupts the middle of your function. This is the entire foundation of RAII (Resource Acquisition Is Initialization) — the idiom where you tie resource lifetime to object lifetime.
The practical lesson: prefer stack-allocated RAII wrappers (like std::unique_ptr, std::lock_guard, or std::fstream) over raw pointers and manual cleanup. If you use a raw pointer and an exception fires before you call delete, you have a memory leak. If you use std::unique_ptr, the destructor runs during unwinding and the memory is freed automatically.
This design is deliberate and powerful. C++ exception handling was built around the assumption that destructors are the cleanup mechanism. Respecting that assumption is what makes exception-safe code possible.
#include <iostream> #include <memory> // for std::unique_ptr #include <stdexcept> #include <string> // A simple RAII wrapper that logs when it's created and destroyed // This models any real resource: a file, a DB connection, a mutex lock class DatabaseConnection { public: explicit DatabaseConnection(const std::string& host) : host_(host) { std::cout << " [DB] Connected to " << host_ << "\n"; } void executeQuery(const std::string& sql) { std::cout << " [DB] Running: " << sql << "\n"; // Simulate a query that fails on bad SQL if (sql.find("DROP") != std::string::npos) { throw std::runtime_error("Refusing dangerous query: " + sql); } } // Destructor runs automatically — even during stack unwinding! ~DatabaseConnection() { std::cout << " [DB] Connection to " << host_ << " closed cleanly.\n"; } private: std::string host_; }; void runReportJob() { // unique_ptr ensures DatabaseConnection is destroyed when this // function exits — whether normally OR via exception auto db = std::make_unique<DatabaseConnection>("prod-db-01"); db->executeQuery("SELECT * FROM orders WHERE date = '2024-01-01'"); // This line throws — but the destructor of `db` WILL still run db->executeQuery("DROP TABLE orders"); // <-- exception thrown here // This line is never reached db->executeQuery("COMMIT"); } int main() { std::cout << "Starting report job...\n"; try { runReportJob(); } catch (const std::runtime_error& error) { // We land here after the stack has been fully unwound // Notice the DB destructor already ran before we got here std::cerr << "\nJob failed: " << error.what() << "\n"; } std::cout << "\nSystem remains stable. No resource leak.\n"; return 0; }
[DB] Connected to prod-db-01
[DB] Running: SELECT * FROM orders WHERE date = '2024-01-01'
[DB] Running: DROP TABLE orders
[DB] Connection to prod-db-01 closed cleanly.
Job failed: Refusing dangerous query: DROP TABLE orders
System remains stable. No resource leak.
Custom Exception Hierarchies — Building Exceptions That Communicate
The standard library exceptions (std::runtime_error, std::logic_error, etc.) are useful starting points, but in any real project you'll want custom exception types. Why? Because a catch block that receives a bare std::runtime_error has to parse a string to figure out what happened. A custom exception type carries structured data.
The correct approach is to derive your custom exceptions from std::exception or one of its children. This means any caller that writes catch(const std::exception& e) will still be able to catch your custom exceptions — preserving compatibility with generic error handlers.
Design your exception hierarchy to reflect the structure of your application. A payment processing library might have a PaymentException base class, with CardDeclinedException and InsufficientFundsException as children. Code that handles both the same way catches PaymentException. Code that needs to handle them differently catches each specific type.
Keep exception classes lightweight. They're constructed at the throw site (which is already in an error path) and must be copyable — the C++ standard requires exception objects to be copy-constructible because the runtime may copy them internally. Don't put heavy resources in exception classes.
#include <iostream> #include <stdexcept> #include <string> // ---- Custom Exception Hierarchy ---- // Base class for all errors in our payment module. // Inheriting from std::runtime_error gives us what() for free. class PaymentException : public std::runtime_error { public: explicit PaymentException(const std::string& message) : std::runtime_error("[Payment] " + message) {} }; // Specific error: the card was declined by the bank class CardDeclinedException : public PaymentException { public: CardDeclinedException(const std::string& lastFourDigits, const std::string& reason) : PaymentException("Card ending in " + lastFourDigits + " declined: " + reason), lastFourDigits_(lastFourDigits) {} // Extra structured data only this exception type carries const std::string& lastFourDigits() const { return lastFourDigits_; } private: std::string lastFourDigits_; }; // Specific error: account doesn't have enough funds class InsufficientFundsException : public PaymentException { public: InsufficientFundsException(double available, double required) : PaymentException( "Need $" + std::to_string(required) + " but only $" + std::to_string(available) + " available"), shortfall_(required - available) {} double shortfall() const { return shortfall_; } private: double shortfall_; }; // ---- Simulated Payment Processor ---- void processPayment(const std::string& cardNumber, double amount, double accountBalance) { // Simulate a declined card if (cardNumber == "4111-DECLINED") { throw CardDeclinedException("1234", "suspected fraud"); } // Simulate insufficient funds if (accountBalance < amount) { throw InsufficientFundsException(accountBalance, amount); } std::cout << "Payment of $" << amount << " processed successfully.\n"; } int main() { // --- Scenario 1: Card declined --- std::cout << "--- Scenario 1: Declined Card ---\n"; try { processPayment("4111-DECLINED", 99.99, 500.00); } catch (const CardDeclinedException& error) { // We get structured data, not just a string std::cerr << error.what() << "\n"; std::cerr << "Suggest user update card ending in: " << error.lastFourDigits() << "\n"; } catch (const PaymentException& error) { // Generic payment handler — catches anything else in the hierarchy std::cerr << "Payment failed: " << error.what() << "\n"; } // --- Scenario 2: Insufficient funds --- std::cout << "\n--- Scenario 2: Insufficient Funds ---\n"; try { processPayment("4111-OK", 250.00, 100.00); } catch (const InsufficientFundsException& error) { std::cerr << error.what() << "\n"; std::cerr << "Shortfall: $" << error.shortfall() << ". Suggest top-up.\n"; } catch (const PaymentException& error) { std::cerr << "Payment failed: " << error.what() << "\n"; } // --- Scenario 3: Success --- std::cout << "\n--- Scenario 3: Successful Payment ---\n"; try { processPayment("4111-OK", 49.99, 500.00); } catch (const PaymentException& error) { std::cerr << "Payment failed: " << error.what() << "\n"; } return 0; }
[Payment] Card ending in 1234 declined: suspected fraud
Suggest user update card ending in: 1234
--- Scenario 2: Insufficient Funds ---
[Payment] Need $250.000000 but only $100.000000 available
Shortfall: $150.000000. Suggest top-up.
--- Scenario 3: Successful Payment ---
Payment of $49.99 processed successfully.
Exception Safety Guarantees — The Contract Your Code Makes with Callers
This is the concept that separates intermediate from senior C++ developers. Exception safety isn't just about 'not crashing' — it's about formally specifying what state your program is in if an exception occurs. The C++ community recognizes four levels, and knowing them will change how you design functions.
The No-throw guarantee means the function never throws — period. Destructors and memory deallocation must provide this. Mark these with noexcept. If a noexcept function does somehow throw, std::terminate() is called immediately — there's no catch.
The Strong guarantee means if an exception occurs, the program state is exactly as it was before the function was called. It either fully succeeds or fully rolls back. Think of database transactions. Implement this with copy-then-swap.
The Basic guarantee means if an exception occurs, no resources are leaked and the program is in some valid (though unspecified) state. Most well-written code achieves at least this level through RAII.
The No guarantee means anything can happen — state could be corrupted, resources leaked. This is what you get with naive raw-pointer code and is unacceptable in production. The right use of noexcept matters for performance too: the compiler can generate faster code for move operations and standard container operations when it knows they won't throw.
#include <iostream> #include <vector> #include <string> #include <stdexcept> // Demonstrates the STRONG exception safety guarantee using copy-then-swap. // The goal: either the update fully succeeds, or the object is UNCHANGED. class UserProfile { public: UserProfile(std::string name, std::string email) : name_(std::move(name)), email_(std::move(email)) {} // UNSAFE version — partial updates leave object in broken state void updateUnsafe(const std::string& newName, const std::string& newEmail) { name_ = newName; // succeeds email_ = newEmail; // if THIS threw, name_ is already changed — broken state! validateEmail(); // imagine this throws for invalid email format } // SAFE version — uses copy-then-swap for strong guarantee void updateSafe(const std::string& newName, const std::string& newEmail) { // Step 1: Work on a COPY. If anything throws here, 'this' is untouched. UserProfile temp(newName, newEmail); temp.validateEmail(); // validate on the copy — throws if invalid // Step 2: Only if everything succeeded, swap with noexcept swap. // swap itself must not throw — otherwise the guarantee breaks. using std::swap; swap(name_, temp.name_); // noexcept swap on strings swap(email_, temp.email_); // noexcept swap on strings // If we got here, the update is fully committed. } void print() const { std::cout << " Name: " << name_ << ", Email: " << email_ << "\n"; } private: std::string name_; std::string email_; void validateEmail() const { // Simplified validation — real code would be more thorough if (email_.find('@') == std::string::npos) { throw std::invalid_argument( "Invalid email format: '" + email_ + "' — missing '@'" ); } } }; int main() { UserProfile profile("Alice", "alice@example.com"); std::cout << "Before update:\n"; profile.print(); // --- Try the SAFE update with invalid email --- std::cout << "\nAttempting safe update with bad email...\n"; try { profile.updateSafe("Alice Smith", "not-a-valid-email"); } catch (const std::invalid_argument& error) { std::cerr << "Update rejected: " << error.what() << "\n"; } // The profile is COMPLETELY UNCHANGED — strong guarantee held std::cout << "After failed safe update (should be unchanged):\n"; profile.print(); // --- Now do a valid update --- std::cout << "\nAttempting safe update with good email...\n"; try { profile.updateSafe("Alice Smith", "alice.smith@example.com"); } catch (const std::invalid_argument& error) { std::cerr << "Update rejected: " << error.what() << "\n"; } std::cout << "After successful safe update:\n"; profile.print(); return 0; }
Name: Alice, Email: alice@example.com
Attempting safe update with bad email...
Update rejected: Invalid email format: 'not-a-valid-email' — missing '@'
After failed safe update (should be unchanged):
Name: Alice, Email: alice@example.com
Attempting safe update with good email...
After successful safe update:
Name: Alice Smith, Email: alice.smith@example.com
| Aspect | Return Codes (C-style) | C++ Exceptions |
|---|---|---|
| Error propagation | Manual — every caller must check and forward | Automatic — propagates up the call stack without caller involvement |
| Forgetting to handle | Silent — missed check means error disappears | Loud — uncaught exception calls std::terminate(), crash is obvious |
| Error context | Just a number (e.g., -1, errno) | Rich object with message, type, extra fields, full hierarchy |
| Performance on success | Zero overhead — just a return value | Zero overhead on the happy path with modern compilers (zero-cost model) |
| Performance on failure | Minimal — just a branch check | Higher — stack unwinding is not free; exceptions are for exceptional cases only |
| Resource safety | Manual cleanup required before each return | Automatic via RAII and stack unwinding during exception propagation |
| Separation of concerns | Error handling mixed into normal logic | Error handling cleanly separated into catch blocks |
| Best used when | Performance-critical hot paths, C interop | All other cases — especially library boundaries and constructor failures |
🎯 Key Takeaways
- Always catch by const reference (
const std::exception& e), always throw by value — catching by value causes object slicing that silently discards your exception's type and data. - Stack unwinding is your safety net: RAII objects (unique_ptr, fstream, lock_guard) have their destructors called automatically during unwinding, so resource leaks are impossible if you avoid raw resource ownership.
- Exception hierarchies should mirror your application's domain — a PaymentException base with specific children lets callers choose how granularly they want to handle errors, without breaking generic
catch(std::exception&)handlers. - Exceptions are for exceptional cases — they carry zero overhead on the happy path (zero-cost exception model), but unwinding is expensive. Don't use throw/catch for control flow like validating a loop counter; reserve them for things that genuinely shouldn't happen in normal operation.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Throwing by pointer instead of by value —
throw new MyException()leaks memory if the catcher doesn't call delete (and it often forgets). Always throw by value:throw MyException("message"). C++ copies the exception object into a safe internal storage area automatically. - ✕Mistake 2: Catching by value instead of by reference —
catch(std::exception e)slices the exception: if a CardDeclinedException was thrown, you receive a truncated std::exception copy and lose all the derived-class data and the correct what() message. Always catch by const reference:catch(const std::exception& e). - ✕Mistake 3: Throwing inside a destructor — if an exception is already propagating and your destructor throws another one, C++ calls std::terminate() immediately — no catch block runs, your program dies. Destructors must never throw. Wrap any risky cleanup code inside the destructor in its own try-catch and log or swallow the error there.
Interview Questions on This Topic
- QWhat is stack unwinding in C++ and why is it critical for resource safety? Can you trace through what happens between the throw and catch when a function three levels deep throws an exception?
- QExplain the four levels of exception safety guarantees. How would you implement the strong exception safety guarantee for a function that updates two member variables? What is the copy-then-swap idiom and why does it work?
- QWhat happens if an exception is thrown inside a noexcept function? And what happens if an exception is thrown inside a destructor while another exception is already propagating? Why does C++ make that choice?
Frequently Asked Questions
What is the difference between std::runtime_error and std::logic_error in C++?
std::logic_error represents bugs in the program itself — conditions that could have been detected before runtime, like passing a null pointer where one isn't allowed. std::runtime_error represents conditions outside the program's control — file not found, network timeout, bad user input. As a rule of thumb: if the developer could have prevented it with better code, it's a logic_error; if the environment caused it, it's a runtime_error.
Should I use exceptions or error codes in C++?
Use exceptions as your default for application code, library boundaries, and any situation where constructors need to signal failure (constructors can't return error codes). Use error codes when you're writing performance-critical hot paths called millions of times per second, when interoperating with C APIs, or when your codebase has a strict no-exceptions policy (common in embedded systems). The two approaches can coexist — just don't mix them at the same API boundary without a clear translation layer.
Can I catch all exceptions with a single catch block in C++?
Yes — catch(...) with literal ellipsis catches every possible exception, including non-std::exception types. Use it sparingly and only as a last-resort safety net (e.g., at the top of a thread function or in main()). Because it gives you no information about the exception — you can't call .what() or inspect the type — it should almost always rethrow using a bare throw; statement after logging, or at minimum log a generic 'unknown exception caught' message before deciding whether to rethrow or terminate.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.