What Is Rethrowing an Exception?

Rethrowing puts an exception that a handler has already caught back into flight, so the search for a handler carries on in the caller. The handler still gets its turn: it can write a diagnostic line, hand back a resource, or undo work it started. What it deliberately does not do is decide the outcome, because that decision belongs further up the call stack.

Think of it as splitting one job between two places. A function deep in the call chain usually knows precisely what went wrong and nothing at all about what the program should do about it. A function near main() is the reverse: it can choose a response, retry, or shut down cleanly, but it has no view of the detail. Rethrowing lets each one contribute the part it is qualified to contribute.

Three Ways a Handler Can End

Control that reaches a catch block has three useful ways to leave it, and the whole lesson is a tour of those three.

Ending What leaves the handler Written as
The failure stops here nothing; execution resumes after the try/catch ordinary code, no throw at all
A different failure reaches the caller a brand new object, of whatever type suits the caller throw SomeOtherType{...};
The original failure reaches the caller the very object that was caught, untouched throw;

The first ending is the one you already know from earlier lessons. The other two exist for the situation described next.

When a Handler Has Nowhere to Put the Failure

Some functions can turn a failure into an ordinary return value, and those never need any of this. Here the return type is a string, and the empty string is a value no successful call would ever produce, so it is free to carry the bad news:

// returns an empty string when no label could be built
std::string labelForSlot(int slotIndex)
{
    try
    {
        return buildLabel(slotIndex); // signals BadRecord when a slot is unknown
    }
    catch (const BadRecord&)
    {
        recordFailure("slot label unavailable");
    }

    return {};
}

Now change the return type to int and the trick stops working:

// every int is a legitimate reading, so no return value is spare
int readingForSlot(int slotIndex)
{
    try
    {
        return meterReading(slotIndex); // signals BadRecord when a slot is unknown
    }
    catch (const BadRecord&)
    {
        recordFailure("slot reading unavailable");
        // recorded, yet unresolved, and no repair is possible so far down
    }
}

Zero is a plausible reading. So is -1, and so is every other int. The handler has recorded what happened and has run out of moves. No value is left over to mean failure, and dropping off the bottom of the function is not an escape either: a non-void function that returns nothing is undefined behaviour, and the caller ends up reading a value nobody produced. Those are exactly the circumstances the remaining two endings were designed for.

Sending a Different Exception to the Caller

The first option is to let a fresh object leave the handler. It carries whatever type and content the caller is best able to act on, and it need not resemble the object that was caught:

#include <iostream>
#include <string>

struct BadRecord
{
    int lineNumber{};
};

struct ImportFailed
{
    std::string reason{};
};

int quantityOnLine(int lineNumber)
{
    if (lineNumber == 4)
        throw BadRecord{lineNumber};

    return lineNumber * 3;
}

int importQuantity(int lineNumber)
{
    try
    {
        return quantityOnLine(lineNumber);
    }
    catch (const BadRecord& raw)
    {
        std::cout << "[trail] line " << raw.lineNumber << " is unreadable\n";
        throw ImportFailed{"stock import abandoned"}; // a new type, chosen for whoever called us
    }
}

int main()
{
    try
    {
        std::cout << importQuantity(2) << '\n';
        std::cout << importQuantity(4) << '\n';
    }
    catch (const ImportFailed& stopped)
    {
        std::cout << "main was told: " << stopped.reason << '\n';
    }

    return 0;
}
6
[trail] line 4 is unreadable
main was told: stock import abandoned

Two things in that output are worth pausing on.

First, main() received the ImportFailed, not the BadRecord. importQuantity() translated a parsing detail into a word its caller understands, which is the usual reason to throw a new object from a handler: the layer that catches speaks one vocabulary and the layer above it speaks another.

Second, the throw inside the catch block did not land back in that same catch block, nor in any sibling handler attached to the same try. A handler sits outside the region its try protects, so anything thrown from inside it has already escaped that try and the stack search resumes in the caller. Were it otherwise, a handler that threw would have a fair chance of catching itself forever.

Key Concept
Translation is the value here. Callers of an import routine should not have to know that the failure originally came from a line parser, a socket, or a disk. Give them a type from their own layer and they can respond to it without depending on details you may replace next month.

Naming the Object in a Throw Makes a Copy

The second option is to send the original failure on, and there are two spellings for that. The one people reach for first is to name the handler's variable in a throw. This program makes visible what that spelling costs, by giving the class a copy constructor that announces itself:

#include <iostream>

class Ticket
{
public:
    Ticket() = default;
    Ticket(const Ticket&) { std::cout << "  copy constructor ran\n"; }
};

void sendByName()
{
    try
    {
        throw Ticket{};
    }
    catch (const Ticket& held)
    {
        throw held;
    }
}

void sendBare()
{
    try
    {
        throw Ticket{};
    }
    catch (const Ticket&)
    {
        throw;
    }
}

int main()
{
    std::cout << "handler used throw held;\n";
    try
    {
        sendByName();
    }
    catch (const Ticket&)
    {
    }

    std::cout << "handler used throw;\n";
    try
    {
        sendBare();
    }
    catch (const Ticket&)
    {
    }

    return 0;
}
handler used throw held;
  copy constructor ran
handler used throw;

throw held; did not send the object the handler was looking at. It built a second object from it and sent that, and the optimiser did not remove the construction even at -O2. The bare throw; in the other function produced no such line, because there was nothing to construct.

For a small class the wasted construction is a minor cost. The next section shows the part that is not minor.

The Copy Keeps Only the Base Part

Exception hierarchies are common, and a handler that catches by base reference will happily bind to a derived object. Rethrowing by name in that situation destroys the very information the hierarchy exists to carry:

#include <iostream>
#include <string_view>

class NetworkError
{
public:
    virtual ~NetworkError() = default;
    virtual std::string_view label() const { return "NetworkError"; }
};

class TimeoutError : public NetworkError
{
public:
    std::string_view label() const override { return "TimeoutError"; }
};

void forwardFailure()
{
    try
    {
        throw TimeoutError{};
    }
    catch (const NetworkError& inFlight)
    {
        std::cout << "inner handler holds a " << inFlight.label() << '\n';
        throw inFlight; // builds a NetworkError, dropping every TimeoutError part
    }
}

int main()
{
    try
    {
        forwardFailure();
    }
    catch (const NetworkError& arrived)
    {
        std::cout << "outer handler holds a " << arrived.label() << '\n';
    }

    return 0;
}
inner handler holds a TimeoutError
outer handler holds a NetworkError

The virtual call proves it. Inside forwardFailure(), inFlight refers to a genuine TimeoutError, so label() reports one. The new object built by throw inFlight; takes its type from the reference, and that reference is declared const NetworkError&, so what leaves the function is a plain NetworkError. Everything the derived class added, including the override, is gone by the time main() looks at it. That trimming is object slicing, the same effect you saw when a derived object was copied into a base variable.

Warning
Catching by base reference and then rethrowing by name is the combination that loses information. The caller receives a base object built from the base part alone, so the specific failure type is unrecoverable and any diagnostic that depended on it reports the wrong thing.

Handing On the Original With a Bare throw

Write throw with nothing after it and the language reactivates the object already in flight. Nothing is constructed, nothing is converted, and the dynamic type stays what it was when the object was first thrown:

#include <iostream>
#include <string_view>

class NetworkError
{
public:
    virtual ~NetworkError() = default;
    virtual std::string_view label() const { return "NetworkError"; }
};

class TimeoutError : public NetworkError
{
public:
    std::string_view label() const override { return "TimeoutError"; }
};

void pollSensor()
{
    throw TimeoutError{};
}

void retryOnce()
{
    try
    {
        pollSensor();
    }
    catch (const NetworkError& inFlight)
    {
        std::cout << "retry layer holds a " << inFlight.label() << '\n';
        throw; // same TimeoutError, still intact, still moving
    }
}

int main()
{
    try
    {
        retryOnce();
    }
    catch (const NetworkError& arrived)
    {
        std::cout << "outer handler holds a " << arrived.label() << '\n';
    }

    return 0;
}
retry layer holds a TimeoutError
outer handler holds a TimeoutError

Same classes, same pair of handlers, and the only change that counts is the operand that is no longer written after throw. This time the derived type survives the trip. retryOnce() got to log the attempt on its way past, which is the whole point of stopping there at all.

Best Practice
Whenever the caller should see the object you already have, write throw; and nothing else. The moment a name follows throw, you have asked the compiler for a new object, and a handler that caught by base reference will get that new object built at the base type.

A Bare throw Reaches Beyond the Handler's Braces

The bare form is not tied to the text of the catch block. It rethrows whichever exception the current thread is handling, so a shared helper called from a handler can do the logging and the rethrow together:

#include <iostream>
#include <string_view>

struct BadRecord
{
    int lineNumber{};
};

void recordAndEscalate(std::string_view detail)
{
    std::cout << "[trail] " << detail << '\n';
    throw;
}

int quantityOnLine(int lineNumber)
{
    try
    {
        if (lineNumber == 4)
            throw BadRecord{lineNumber};

        return lineNumber * 3;
    }
    catch (const BadRecord&)
    {
        recordAndEscalate("line 4 rejected during parsing");
    }

    return 0;
}

int main()
{
    try
    {
        std::cout << quantityOnLine(4) << '\n';
    }
    catch (const BadRecord& raw)
    {
        std::cout << "main resolved a record from line " << raw.lineNumber << '\n';
    }

    return 0;
}
[trail] line 4 rejected during parsing
main resolved a record from line 4

recordAndEscalate() never names a type and never takes the object as a parameter, yet the BadRecord arrives in main() intact. That works because the rethrow reads the thread's active exception rather than any local variable.

Warning
The same property makes the bare form dangerous in the wrong place. A throw; reached while no exception is being handled calls std::terminate(), and the program is over. Keep helpers like recordAndEscalate() private to the code that calls them from a handler, and never make one part of a public interface.

Choosing an Ending

Work out what the handler is genuinely able to accomplish, and the ending picks itself.

What this handler can actually accomplish Ending to use
Put the program back in a good state and continue finish inside the handler; throw nothing
Release a lock, close a file, or roll back an edit, but not decide the outcome do the cleanup, then throw;
Record the failure for whoever reads the logs, and no more write the entry, then throw;
Restate a low-level failure in the caller's own vocabulary throw a new object of the caller's type
Attach context the caller cannot work out for itself, such as a file name or a row number throw a new object carrying that context
Key Concept
Rethrowing is not a way to avoid the question of who handles an error. It is a way to record that this level saw the failure, did the part it could, and passed a still-unanswered question to a level that can answer it. Something above you must still answer it, or the program terminates.

Looking Forward

Both endings covered here move an exception up the stack immediately. C++ also lets you store one and rethrow it much later, on another thread if you like, through std::exception_ptr and std::current_exception(). That is how a thread pool reports a worker's failure back to the code that queued the work. The technique builds directly on the bare rethrow you learned in this lesson.

Key Terminology

Rethrow: sending an already-caught exception onward, so the search for a handler resumes in the caller.

Bare throw: the statement throw; with no operand, which puts the currently handled exception back into flight without copying it.

Exception translation: catching an object from one layer and throwing a different type that suits the layer above.

Object slicing: initializing a base-typed object from a derived one, keeping the base part and discarding everything the derived class added.

Partial handling: reacting to a failure with logging or cleanup while leaving the actual recovery to another level.

Summary

  • A handler that cannot resolve a failure has two ways to keep it moving: throw a new object, or rethrow the one it caught
  • Functions whose return type has a spare value can report failure through that value instead; functions where every possible return is meaningful cannot, and those are where rethrowing earns its place
  • Throwing from inside a catch block is legal, and the new exception is never offered to that block or to any other handler on the same try, because a handler lies outside the region its try protects
  • The object thrown from a handler may be any type at all, which is what makes exception translation between layers possible
  • throw named; constructs a new exception object initialized from named, and the run in this lesson shows the copy constructor firing even under -O2
  • When the handler caught by base reference, that new object is built at the base type, so a derived exception is sliced and its extra state and overrides are lost before the caller sees it
  • throw; reactivates the original object instead of copying it, so both the value and the dynamic type reach the next handler unchanged
  • A bare throw; refers to the thread's currently handled exception, so it still works from inside a function called by the handler, and calls std::terminate() if no exception is being handled at the time