What Are the Dangers and Downsides of Exceptions?

Throwing an exception is an abrupt transfer of control out of one or more function bodies, and the only work the language performs on the way out is running the destructors of automatic objects that were fully constructed. Anything else the code still owed at that instant, a hoist to disengage, a pointer to delete, a half-written record to roll back, is your problem, and nothing in the language will notice if you forget.

Almost every complaint made about exceptions is a consequence of that one sentence. Resources leak because the statement that would have released them was jumped over. Programs vanish without printing a handler message because a destructor threw while unwinding was already in progress. Binaries get larger, and failing gets slower, because unwinding needs tables and a search. This lesson follows those consequences in order, and finishes with the question worth asking before any of them apply: does this particular failure deserve an exception at all?

The Only Cleanup an Exception Performs by Itself

Start with what does work, because the guarantee is narrow and precise:

#include <iostream>
#include <string_view>

struct CueFault
{
    std::string_view reason{};
};

class DimmerChannel
{
private:
    int m_channel{};

public:
    explicit DimmerChannel(int channel) : m_channel{ channel }
    {
        std::cout << "channel " << m_channel << " energised" << '\n';
    }

    ~DimmerChannel()
    {
        std::cout << "channel " << m_channel << " released" << '\n';
    }
};

void runCue(int level)
{
    DimmerChannel front{ 12 };

    if (level > 100)
    {
        throw CueFault{ "level above the dimmer curve" };
    }

    std::cout << "cue holding at " << level << '\n';
}

int main()
{
    try
    {
        runCue(140);
    }
    catch (const CueFault& fault)
    {
        std::cout << "cue aborted: " << fault.reason << '\n';
    }

    return 0;
}
channel 12 energised
channel 12 released
cue aborted: level above the dimmer curve

The front object was fully constructed when the throw happened, so unwinding destroyed it, and its release line printed before the handler body ever ran. That ordering is worth noticing: cleanup driven by destructors happens on the way to the handler, not after it.

The Guarantee, Stated Exactly
Unwinding destroys the fully constructed automatic objects in every frame between the throw and the matching handler, in reverse order of construction. That is the entire list. It runs no other statement of yours, frees no memory that no object owns, and calls no release function that you paired with an earlier acquire by hand.

Every Other Line Is Simply Skipped

The following program is wrong, and it is wrong in the way beginners are most likely to write it. A resource is taken, work is done, the resource is given back on the next line:

#include <iostream>
#include <string_view>

struct HoistFault
{
    std::string_view reason{};
};

int engageHoist(int position)
{
    std::cout << "hoist " << position << " engaged" << '\n';
    return position;
}

void releaseHoist(int position)
{
    std::cout << "hoist " << position << " released" << '\n';
}

void raiseTruss(int metres)
{
    if (metres > 8)
    {
        throw HoistFault{ "travel beyond the rated height" };
    }
}

void flyTrussIn(int metres)
{
    int hoist{ engageHoist(4) };
    raiseTruss(metres);
    releaseHoist(hoist);
}

int main()
{
    try
    {
        flyTrussIn(11);
    }
    catch (const HoistFault& fault)
    {
        std::cout << "truss move abandoned: " << fault.reason << '\n';
    }

    return 0;
}
hoist 4 engaged
truss move abandoned: travel beyond the rated height

The release line never printed. releaseHoist() is an ordinary statement sitting after a call that threw, so control left flyTrussIn() without ever reaching it, and the hoist stayed engaged for the rest of the run. Nothing about this is exotic: an int holding a handle has no destructor, so unwinding has nothing to do on its behalf.

Every version of this bug has the same shape. Something is acquired, something in between can throw, and the matching release is written as a statement rather than owned by an object. A new with a matching delete two lines below it is the same bug with different words.

The Handler Cannot Reach What the Try Block Owned

The obvious repair is to do the cleanup in the handler instead. With dynamically allocated memory that repair will not compile, and the reason is worth understanding rather than working around:

#include <iostream>
#include <string_view>

struct CueFault
{
    std::string_view reason{};
};

class CueSheet
{
private:
    std::string_view m_act{};

public:
    explicit CueSheet(std::string_view act) : m_act{ act }
    {
        std::cout << "cue sheet for " << m_act << " loaded" << '\n';
    }

    ~CueSheet()
    {
        std::cout << "cue sheet for " << m_act << " discarded" << '\n';
    }
};

void renderCue(const CueSheet&)
{
    throw CueFault{ "no dimmer patched to channel 12" };
}

int main()
{
    try
    {
        auto* sheet{ new CueSheet{ "act one" } };
        renderCue(*sheet);
        delete sheet;
    }
    catch (const CueFault& fault)
    {
        std::cout << "cue aborted: " << fault.reason << '\n';
        delete sheet;
    }

    return 0;
}

GCC rejects the handler with error: 'sheet' was not declared in this scope. A try block is a block like any other, so sheet is a local variable of it, and by the time a handler runs its block has already been exited. The pointer variable is gone; the object it addressed is not. That is the worst combination available: the memory is unreachable and still allocated.

Note carefully which thing was lost. The CueSheet object on the heap has no destructor call scheduled, because no automatic object owned it. The pointer, which was automatic, was destroyed exactly as promised, and destroying a raw pointer does nothing at all.

Three Repairs, and Only One of Them Scales

Repair What it still gets wrong Verdict
Move the release below the try block It also runs when the acquire itself failed, and any return or break inside the block still skips it last resort
Declare the handle above the try, clean up after the handler Correct, but every resource needs its own variable, its own null check, and its own release, all kept in step by hand workable for one resource
Hand the resource to an object whose destructor releases it Nothing. It reuses the one guarantee unwinding already makes the one that scales

The middle repair looks like this. Hoisting the pointer out of the try block keeps it alive and visible for the rest of the function:

#include <iostream>
#include <string_view>

struct CueFault
{
    std::string_view reason{};
};

class CueSheet
{
private:
    std::string_view m_act{};

public:
    explicit CueSheet(std::string_view act) : m_act{ act }
    {
        std::cout << "cue sheet for " << m_act << " loaded" << '\n';
    }

    ~CueSheet()
    {
        std::cout << "cue sheet for " << m_act << " discarded" << '\n';
    }
};

void renderCue(const CueSheet&)
{
    throw CueFault{ "no dimmer patched to channel 12" };
}

int main()
{
    CueSheet* sheet{ nullptr };

    try
    {
        sheet = new CueSheet{ "act one" };
        renderCue(*sheet);
    }
    catch (const CueFault& fault)
    {
        std::cout << "cue aborted: " << fault.reason << '\n';
    }

    delete sheet;

    return 0;
}
cue sheet for act one loaded
cue aborted: no dimmer patched to channel 12
cue sheet for act one discarded

Nothing leaks, and delete on a null pointer is harmless when the allocation itself is what failed. The cost is that correctness now depends on a human remembering, which is precisely the kind of promise that decays as a function grows a second resource and a third early return.

The third repair moves the promise into a type. std::unique_ptr holds a pointer and deletes it when it goes out of scope, so the release becomes a destructor call and inherits the unwinding guarantee:

#include <iostream>
#include <memory>
#include <string_view>

struct CueFault
{
    std::string_view reason{};
};

class CueSheet
{
private:
    std::string_view m_act{};

public:
    explicit CueSheet(std::string_view act) : m_act{ act }
    {
        std::cout << "cue sheet for " << m_act << " loaded" << '\n';
    }

    ~CueSheet()
    {
        std::cout << "cue sheet for " << m_act << " discarded" << '\n';
    }
};

void renderCue(const CueSheet&)
{
    throw CueFault{ "no dimmer patched to channel 12" };
}

int main()
{
    try
    {
        auto sheet{ std::make_unique<CueSheet>("act one") };
        renderCue(*sheet);
    }
    catch (const CueFault& fault)
    {
        std::cout << "cue aborted: " << fault.reason << '\n';
    }

    return 0;
}
cue sheet for act one loaded
cue sheet for act one discarded
cue aborted: no dimmer patched to channel 12

Compare the two output listings line by line. Under the manual repair the sheet was discarded after the handler finished; under std::unique_ptr it was discarded during unwinding, before the handler started. The second ordering is the one you want, because it holds no matter how the block is left: a throw, a return, a break, or simply falling off the end.

The same trick works for resources that are not memory at all. Give the hoist an owner and the broken example from earlier repairs itself:

#include <iostream>
#include <string_view>

struct HoistFault
{
    std::string_view reason{};
};

int engageHoist(int position)
{
    std::cout << "hoist " << position << " engaged" << '\n';
    return position;
}

void releaseHoist(int position)
{
    std::cout << "hoist " << position << " released" << '\n';
}

void raiseTruss(int metres)
{
    if (metres > 8)
    {
        throw HoistFault{ "travel beyond the rated height" };
    }
}

class HoistLock
{
private:
    int m_position{};

public:
    explicit HoistLock(int position) : m_position{ engageHoist(position) }
    {
    }

    ~HoistLock()
    {
        releaseHoist(m_position);
    }

    HoistLock(const HoistLock&) = delete;
    HoistLock& operator=(const HoistLock&) = delete;
};

void flyTrussIn(int metres)
{
    HoistLock lock{ 4 };
    raiseTruss(metres);
}

int main()
{
    try
    {
        flyTrussIn(11);
    }
    catch (const HoistFault& fault)
    {
        std::cout << "truss move abandoned: " << fault.reason << '\n';
    }

    return 0;
}
hoist 4 engaged
hoist 4 released
truss move abandoned: travel beyond the rated height

A class that takes a resource in its constructor and gives it back in its destructor is following RAII, Resource Acquisition Is Initialization. The point of the pattern is not tidiness; it is that destructors are the only cleanup an exception is willing to run, so anything expressed as a destructor is automatically exception safe, and anything expressed as a statement is not.

Best Practice
Give every resource an owner with a destructor, and prefer that owner to be a local object rather than something reached through new. Once no cleanup is written as a bare statement, the try block has nothing left to leak.

A Destructor That Throws Takes the Program With It

Constructors are a reasonable place to throw from: a constructor that cannot build a valid object has no other way to say so. Destructors are the opposite case, and the reason is unwinding.

Picture an exception already travelling up the stack. The runtime is running destructors along the way, and one of them throws. There are now two exceptions in flight and no rule that could sensibly pick between abandoning the first or ignoring the second, so the standard does not try: it calls std::terminate and the process ends.

#include <iostream>
#include <string_view>

struct CueFault
{
    std::string_view reason{};
};

struct LogFault
{
    std::string_view reason{};
};

class ShowLog
{
public:
    ~ShowLog() noexcept(false)
    {
        throw LogFault{ "log volume full" };
    }
};

void runCue()
{
    ShowLog record{};
    throw CueFault{ "level above the dimmer curve" };
}

int main()
{
    try
    {
        runCue();
    }
    catch (const CueFault& fault)
    {
        std::cout << "cue aborted: " << fault.reason << '\n';
    }

    return 0;
}
terminate called after throwing an instance of 'CueFault'
Aborted

The handler in main() was a perfect match for CueFault and never ran. The runtime reports the exception it was already carrying and aborts, which is a particularly unhelpful diagnostic: the message names the failure you were trying to handle, not the destructor that actually killed the program.

The noexcept(false) on that destructor is doing real work. Destructors are implicitly non-throwing, so writing the destructor the ordinary way makes GCC flag the throw at compile time with warning: 'throw' will always call 'terminate' [-Wterminate], and then the program terminates whether or not another exception is in flight.

Never Throw From a Destructor
An exception leaving a destructor during stack unwinding terminates the program immediately. A destructor that detects a failure should absorb it: write to a log, set a flag the owner can query, or expose a separate close-style function that callers can invoke explicitly when they want to see errors.

What Exceptions Cost When Nothing Goes Wrong

The performance question splits cleanly in three, and mixing them up is where the folklore comes from.

When What you pay
Building the program A larger binary. Handler code, type information for matching, and per-function unwind tables all take space
Running normally, nothing thrown On a table-driven implementation, close to nothing on the happy path. Older schemes that maintained bookkeeping at every entry and exit made ordinary calls slightly slower
The moment something is thrown The expensive part: copy the exception object somewhere safe, walk frames, consult the tables, run destructors, and search for a handler that matches

The table-driven scheme is what people mean by zero-cost exceptions: no additional runtime cost when no exception is thrown, which is the case worth optimising, paid for with an even larger penalty on the throw itself. So the honest summary is not that exceptions are slow. It is that throwing is slow, and everything else is a modest, fixed tax.

Warning
That cost profile makes exceptions a poor mechanism for anything routine. A throw on a path that executes thousands of times per second, or a throw used to break out of a loop, converts the rarest operation in the language into the most frequent one. Exceptions are for the exceptional; normal control flow belongs in `if`, `return`, and loop conditions.

Deciding Whether a Failure Deserves an Exception

Put a candidate failure through four questions. An exception is a good fit only when all four answers point that way.

  1. Is the failure genuinely infrequent, rather than an ordinary outcome the code should expect?
  2. Is it serious enough that this function cannot produce a meaningful result?
  3. Is this the wrong place to fix it, so the decision belongs to some caller further up?
  4. Is there no good alternative channel for reporting it, such as a status enum or a std::optional return?

Question four is usually the one that decides. Take an operator typing a dimmer level into a console. Junk entries are not rare, they are hourly, and the caller has an obvious response: ask again. A return value carries that perfectly well.

#include <iostream>
#include <optional>
#include <string_view>

std::optional<int> readDimmerLevel(std::string_view entry)
{
    if (entry.empty())
    {
        return {};
    }

    int level{ 0 };

    for (char mark : entry)
    {
        if (mark < '0' || mark > '9')
        {
            return {};
        }

        level = level * 10 + (mark - '0');
    }

    if (level > 100)
    {
        return {};
    }

    return level;
}

int main()
{
    for (std::string_view entry : { "65", "9x", "220" })
    {
        std::optional<int> level{ readDimmerLevel(entry) };

        if (level.has_value())
        {
            std::cout << "patched at " << *level << '\n';
        }
        else
        {
            std::cout << "entry " << entry << " needs re-typing" << '\n';
        }
    }

    return 0;
}
patched at 65
entry 9x needs re-typing
entry 220 needs re-typing

Now change one detail. Suppose the same failure is detected six frames deep, inside code whose intermediate functions have no interest in it and no sensible value to return, and whose signatures would all have to grow an error channel to pass it along. The four answers flip, and an exception starts paying for itself: it is exactly the mechanism for delivering a failure to a distant handler without every function in between agreeing to carry it.

Both Mechanisms, Same Program
This is not a choice you make once for a whole codebase. A single program can validate console input with std::optional, report a missing hardware channel with a status enum, and reserve exceptions for the failures that must cross many frames to reach anyone able to act on them.

Summary

One mechanism explains the rest: an exception is a jump, and the only cleanup it performs is destroying fully constructed automatic objects, in reverse order, in each frame it passes through.

Skipped statements leak: any release, close, or delete written as a statement after a call that can throw is simply not executed. The resource stays held.

Try blocks are blocks: a variable declared inside try is out of scope in the handler, so a catch block cannot clean up what the try block allocated. The compiler rejects the attempt outright.

Repairs, in ascending order of reliability: move the cleanup below the try block, or declare the handle above it and clean up after the handler, or give the resource an owner whose destructor releases it. Only the last survives a second resource and a new early return.

RAII and smart pointers: a class that acquires in its constructor and releases in its destructor is exception safe by construction. std::unique_ptr applies that to dynamically allocated objects, and cleanup then happens during unwinding, before the handler runs.

Destructors must not throw: an exception leaving a destructor while another is being unwound gives the runtime two live exceptions and no way to choose, so the program terminates immediately. Log the failure instead.

The cost has three parts: a bigger binary always, close to nothing on the happy path under the zero-cost model, and a genuinely expensive stack walk and handler search when something is thrown. Throwing is what costs, so never use exceptions for ordinary control flow.

Four questions before throwing: rare, serious, not fixable here, and no good alternative return channel. When a status enum or std::optional can carry the failure, prefer it; keep exceptions for failures that must travel far to reach someone who can act.