Skip to content
C++
Language
since C++98
Intermediate

Stack Unwinding

How C++ destroys automatic objects while propagating an exception to a handler, the guarantees it gives, and when it is skipped or fatal.

Stack unwindingsince C++98

Stack unwinding is the process by which, after an exception is thrown, the runtime exits scopes from the throw point outward to the matching handler, destroying every fully constructed automatic object in reverse order of construction along the way.

Overview

When a throw executes, the runtime searches for a matching catch handler, first in the enclosing try block, then in the caller, and so on up the call stack. Each function or block that is exited during this search has its automatic (stack) objects destroyed, as if by a chain of return statements, except that control continues at the handler instead of the caller's next statement.

Key rules:

  • Reverse order. Locals are destroyed in the reverse order of construction, within each scope, from the innermost scope outward.
  • Only constructed objects are destroyed. If a constructor throws, that object's destructor does not run. Fully constructed subobjects and members are destroyed, and so are already-constructed array elements. This is why resource ownership belongs in members with their own destructors.
  • Temporaries and parameters that are alive at the throw point are also destroyed.
  • Free-store objects are not. A raw pointer is destroyed (trivially); the object it points to leaks unless an owner type releases it.
  • Handler matching happens first, but not necessarily unwinding. If no handler is found, it is implementation-defined whether the stack is unwound before std::terminate() is called.

The exception object itself lives in implementation-managed storage and survives until the last handler exits, so unwinding does not destroy it.

Syntax

Unwinding has no dedicated syntax. It is triggered by throw (or by a library or runtime failure that throws) and observed through destructors:

cpp
void f() {
    Guard g;            // destroyed during unwinding
    throw std::runtime_error("boom");
}

try { f(); }
catch (const std::exception& e) { /* g is already destroyed here */ }

By the time the handler body runs, all objects between the throw and the try block have been destroyed.

Examples

Observing destruction order

cpp
#include <iostream>
#include <stdexcept>

struct Trace {
    const char* name;
    explicit Trace(const char* n) : name(n) { std::cout << "ctor " << name << '\n'; }
    ~Trace() { std::cout << "dtor " << name << '\n'; }
};

void inner() {
    Trace a("inner.a");
    Trace b("inner.b");
    throw std::runtime_error("fail");
}

void outer() {
    Trace c("outer.c");
    inner();
    Trace never("outer.never");   // never constructed
}

int main() {
    try { outer(); }
    catch (const std::exception& e) {
        std::cout << "caught: " << e.what() << '\n';
    }
}
// ctor outer.c, ctor inner.a, ctor inner.b,
// dtor inner.b, dtor inner.a, dtor outer.c, caught: fail

Partial construction

cpp
#include <memory>
#include <stdexcept>

struct Connection { Connection() { /* may throw */ } };

class Service {
    std::unique_ptr<int[]> buffer_;   // C++11 (array form), destroyed if ctor throws
    Connection conn_;                 // if this throws, buffer_ is released
public:
    Service() : buffer_(new int[1024]), conn_() {}
    // ~Service() does NOT run if the constructor throws
};

Compare with a version holding int* buffer_ and a delete[] in the destructor: if conn_ throws, the destructor never runs and the buffer leaks.

Scope guard for cleanup without RAII types

cpp
#include <cstdio>
#include <utility>

template <class F>
class ScopeExit {                       // C++11
    F fn_;
    bool active_ = true;
public:
    explicit ScopeExit(F f) : fn_(std::move(f)) {}
    ScopeExit(ScopeExit&& o) noexcept : fn_(std::move(o.fn_)), active_(std::exchange(o.active_, false)) {}
    ~ScopeExit() { if (active_) fn_(); }   // must not throw
    void release() noexcept { active_ = false; }
};

bool process(const char* path) {
    std::FILE* f = std::fopen(path, "rb");
    if (!f) return false;
    ScopeExit close{[f] { std::fclose(f); }};   // C++17 CTAD
    // ... parsing may throw; file is always closed
    return true;
}

Detecting unwinding in a destructor

cpp
#include <exception>

struct Transaction {
    ~Transaction() {
        if (std::uncaught_exceptions() > count_)   // C++17
            rollback();
        else
            commit();                              // commit must handle its own errors
    }
    int count_ = std::uncaught_exceptions();       // C++17
    void rollback() noexcept;
    void commit() noexcept;
};

std::uncaught_exception() (C++98) returns a single bool, was deprecated in C++17, and was removed in C++20. It is unreliable when destructors nest; prefer std::uncaught_exceptions() (C++17), which returns a count.

Best Practices

  • Own resources with objects. std::unique_ptr, std::lock_guard (C++11), std::scoped_lock (C++17), containers, and file/socket wrappers make unwinding free of leaks without catch-and-rethrow.
  • Keep destructors non-throwing. Destructors are implicitly noexcept since C++11. Catch and log inside them if cleanup can fail.
  • Use noexcept deliberately. Move operations and swap should be noexcept so containers can use them safely (e.g., std::vector reallocation, C++11).
  • Don't throw across module boundaries (shared-library/C ABI/plugin interfaces) unless the same runtime and ABI are guaranteed.
  • Use exceptions for recoverable errors, not for violated invariants. For programmer errors, an assertion or std::terminate preserves the failing state for a debugger, whereas unwinding destroys it.
  • Prefer a catch-by-const-reference to avoid slicing; use bare throw; to rethrow the original object.

Common Pitfalls

Exception escaping a destructor during unwinding. If a destructor invoked by unwinding exits via an exception, std::terminate() is called. Since C++11 a destructor is noexcept by default, so any escaping exception terminates, not only those during unwinding (unless declared noexcept(false)).

Assuming unwinding always happens. If an exception reaches main without a handler, or leaves a noexcept function, std::terminate() is called. Whether locals are destroyed beforehand is implementation-defined for the uncaught case, and for noexcept violations the standard permits but does not require unwinding. Don't depend on cleanup there. Wrap main in a top-level try/catch if you want guaranteed destruction.

Bypassing unwinding. std::exit() destroys static objects but not locals; std::abort() and std::_Exit()/quick_exit() (C++11) destroy neither. longjmp over frames with non-trivial destructors is undefined behavior.

Joinable threads. A std::thread (C++11) destroyed while joinable calls std::terminate(), so unwinding past it is fatal. Use std::jthread (C++20), which joins in its destructor, or wrap the thread in a guard. An exception escaping a thread's entry function also terminates the program.

Leaking raw allocations. auto* p = new T; may_throw(); delete p; leaks on throw. Use std::make_unique (C++14).

Cost model. On common 64-bit ABIs (table-driven, "zero-cost" model), the non-throwing path has essentially no overhead, but a throw is expensive: it consults unwind tables and runs personality routines per frame, typically microseconds or more. Don't use exceptions for ordinary control flow in hot paths. On such ABIs, unwind tables also increase binary size; building with -fno-exceptions removes them but also removes the ability to use try/catch.

Constructor/destructor asymmetry. Code that "undoes" work in a destructor won't run if the constructor throws midway. Split responsibilities across members, each with its own RAII type.

See Also