Language[lang]
Can a C++ destructor throw? What happens since C++11
- Section
- Language [lang]
- Standard
- C++20
- Compilers
- MSVC 19.44, Clang 19.1
- Published
- Length
- 9 min read
In this answer7 clauses
- 1The short answer, case by case
- 2What happens when a destructor throws, since C++11
- 3noexcept(false), and why unwinding still ends in std::terminate
- 4Why containers and smart pointers can’t hold such types
- 5Report the failure from an explicit close() instead
- 6Telling a normal exit from unwinding: std::uncaught_exceptions
- 7Find throwing destructors with clang-tidy
Abstract
Since C++11 a destructor is noexcept unless you write noexcept(false) or a base or member destructor can throw, so an exception that leaves it calls std::terminate; noexcept(false) lets it throw on a normal exit, but not during stack unwinding and not inside a standard container. Report a failed close, flush or commit from an explicit member function the caller can catch, and let the destructor clean up quietly.

A destructor can throw only if you opt in, and even then it is rarely safe. Since C++11 a destructor is noexcept unless you write noexcept(false) or a base or member has a destructor that can throw, so an exception that leaves it calls std::terminate. With noexcept(false) the exception reaches the caller on a normal scope exit, but if the destructor throws while the stack is unwinding for another exception, the program still ends in std::terminate, and inside a standard container or smart pointer it is undefined behavior. Report a failed close, flush or commit from an explicit member function the caller can catch, and let the destructor clean up quietly.
This guide is written for C++20. The rules date from C++11, which made destructors noexcept by default; in C++03 an exception thrown by a destructor on a normal scope exit reached the caller like any other, and only the unwinding case ended the program. C++17 added std:: and deprecated the singular std::, which C++20 removed. Every sample was compiled with MSVC 19.44 (/std:c++20 /EHsc /W4 /permissive-) and Clang 19.1 (-std=c++20 -Wall -Wextra, on Microsoft’s standard library) on one Windows x64 machine, without optimization unless the text says otherwise; every program whose output or ending the text describes was run on both, and the outputs, warnings and exit codes shown are what those builds produced there.
What changed: an exception thrown by a destructor on a normal scope exit reaches the caller like any other calls std::terminate, unless the destructor is noexcept(false).
since C++11
The short answer, case by case
| The destructor is | It throws on a normal scope exit | It throws during stack unwinding |
|---|---|---|
noexcept: the default, written or implicit |
std::terminate |
std::terminate |
noexcept(false), written or taken from a member or base |
The caller can catch it | std::terminate |
noexcept(false), destroyed by a standard library type |
Undefined behavior | Undefined behavior |
The standard library’s own types sit in the first row: [res.on.exception.handling] requires every destructor in the library to behave as if it had a non-throwing exception specification.
What happens when a destructor throws, since C++11
Write a destructor with no exception specification and throw from it:
#include <cstdio>
#include <stdexcept>
struct Flusher {
~Flusher() { // implicitly noexcept
throw std::runtime_error("flush failed");
}
};
int main() {
try {
Flusher f;
} catch (const std::exception& e) { // never reached
std::puts(e.what());
}
std::puts("after the try block"); // never reached
}— end example]
Both compilers warn at the throw: MSVC with C4297 (“function assumed not to throw an exception but does”) and Clang with -Wexceptions (“has a non-throwing exception specification but can still throw”). Both programs then printed nothing. The exception can’t leave a non-throwing function, so the search for a handler ends in std::terminate, and the default terminate handler calls std::abort. On this Windows machine both builds ended with exit status 0xC0000409, an error code from the operating system rather than from the program: Microsoft documents that in a release build abort hands the failure to Windows Error Reporting. Other platforms print and exit differently. What the standard guarantees is the call to std::terminate, and it leaves it implementation-defined whether the stack is unwound first (except.terminate), so don’t count on other destructors running or on buffered output reaching its file.
The rule behind it: a destructor declared without a noexcept specifier gets the exception specification the implicit one would have, and that is non-throwing unless the destructor of a base or member is potentially throwing. cppreference’s Destructors page states it the same way. You can check it at compile time:
#include <string>
#include <type_traits>
struct Plain { std::string name; }; // implicit destructor
struct Written { ~Written() {} }; // no noexcept written
struct OptOut { ~OptOut() noexcept(false) {} };
struct Holder { OptOut member; }; // implicit destructor
static_assert(std::is_nothrow_destructible_v<Plain>);
static_assert(std::is_nothrow_destructible_v<Written>);
static_assert(!std::is_nothrow_destructible_v<OptOut>);
static_assert(!std::is_nothrow_destructible_v<Holder>); // inherited— end example]
Holder shows how far one opt-out reaches: a single noexcept(false) member makes the destructor the compiler writes for every class that contains it potentially throwing too. The rule of zero guide covers the other members the compiler writes for you, and when to mark a move constructor noexcept covers checking and writing noexcept on the rest of a class. The noexcept specifier itself is in the C++11 table of Since When, with the first GCC, Clang and MSVC releases that support it.
noexcept(false), and why unwinding still ends in std::terminate
noexcept(false) makes the destructor potentially throwing, and on a normal scope exit its exception propagates like any other. It does nothing for the case that matters most:
#include <iostream>
#include <stdexcept>
struct Flusher {
~Flusher() noexcept(false) {
throw std::runtime_error("flush failed");
}
};
void normal_exit() {
Flusher f;
} // f's destructor throws here, and the caller can catch it
void unwinding() {
Flusher f;
throw std::logic_error("bad input"); // f is destroyed during
} // unwinding: terminate
int main() {
try {
normal_exit();
} catch (const std::exception& e) {
std::cout << "caught: " << e.what() << std::endl;
}
try {
unwinding();
} catch (const std::exception& e) { // never reached
std::cout << "caught: " << e.what() << std::endl;
}
}— end example]
Both builds printed caught: flush failed, then ended as before, with exit status 0xC0000409, without reaching the second catch. normal_exit returns normally, so ~Flusher runs with no other exception in flight and its exception reaches main. In unwinding, ~Flusher runs because bad input is leaving the function, and the draft is explicit: when the destruction of an object during stack unwinding terminates by throwing an exception, std::terminate is called. C++ has no way to carry two exceptions out of one scope. The std::endl is there on purpose: std::abort need not flush output buffers, and with '\n' in its place both builds lost the first line when their output went to a pipe.
A destructor also can’t tell from where it sits which path it is on, because the same object may be destroyed on either. The one tool for asking, std::, comes below, and it tells a destructor when not to throw rather than making a throw safe.
Why containers and smart pointers can’t hold such types
The standard library goes further than the language: the draft’s res.on.functions makes it undefined behavior when a destructor operation of a type you hand to the library exits via an exception, unless the component says it allows it. std::unique_ptr’s destructor states that the behavior is undefined if the deleter throws, and the Destructible requirement says that for u.~T() “no exception is propagated”. So a std::vector<Flusher> compiles, and the first ~Flusher that throws inside it is undefined behavior, not an exception you can catch: the vector may be halfway through destroying its elements, or moving them to a new buffer.
The C++ Core Guidelines make it rule C.36, “A destructor must not fail”, whose reason ends: “The standard library requires that all classes it deals with have destructors that do not exit by throwing.” C.37 adds: make destructors noexcept. In your own templates, the same requirement is one line:
#include <type_traits>
#include <vector>
template <class T>
class Pool {
static_assert(std::is_nothrow_destructible_v<T>,
"Pool<T> needs a destructor that cannot throw");
std::vector<T> items_;
};— end example]
Pool<int> compiles on both compilers; Pool<OptOut> stops at the static_assert with that message.
Report the failure from an explicit close() instead
Cleanup that can fail (closing a file, flushing a buffer, committing a transaction) needs a member function the caller calls and can catch. The destructor becomes the backstop for the paths where nobody called it, such as an early return or an exception already in flight, and on those paths it must not throw:
#include <fstream>
#include <iostream>
#include <stdexcept>
#include <string>
#include <string_view>
class Report {
public:
explicit Report(const std::string& path) : out_(path) {
if (!out_) throw std::runtime_error("cannot open " + path);
}
void write(std::string_view text) { out_ << text; }
void close() { // the caller hears about it
out_.close(); // flushes, then closes
if (out_.fail()) throw std::runtime_error("report not saved");
}
~Report() { // noexcept: cleans up quietly
if (!out_.is_open()) return; // close() already ran
try {
close();
} catch (const std::exception& e) {
std::cerr << "~Report: " << e.what() << '\n';
}
}
private:
std::ofstream out_;
};
void save_totals(const std::string& path) {
Report report(path);
report.write("total: 42\n");
report.close(); // throws if the data didn't reach disk
}— end example]
close() throws when the data didn’t reach the file, so save_totals passes the failure to whoever can retry or tell the user. If save_totals leaves early because something else threw, ~Report closes the file and logs a failure instead of throwing on top of the first exception. Run on both compilers, save_totals wrote its file and returned, and with a path in a directory that doesn’t exist it threw cannot open from the constructor. The standard library’s file streams work the same way: the draft says std::basic_filebuf’s destructor calls close(), and if an exception occurs there it “is caught but not rethrown” ([filebuf.cons]), while a direct call to std:: reports a failed close by setting failbit. A transaction takes the same shape: an explicit commit() that throws, and a destructor that rolls back if commit() was never reached.
Telling a normal exit from unwinding: std::uncaught_exceptions
Sometimes a destructor should act on how its scope ended: undo a change if the scope is left by an exception, keep it otherwise. std:: (C++17) returns the number of exceptions thrown in the current thread that have not yet reached their handler. Record it in the constructor; if it is higher in the destructor, this scope is being unwound:
#include <cstdio>
#include <exception>
#include <stdexcept>
#include <utility>
#include <vector>
// Runs f when the scope is left by an exception, not otherwise.
template <class F>
class on_unwind {
public:
explicit on_unwind(F f)
: f_(std::move(f)), count_(std::uncaught_exceptions()) {}
on_unwind(const on_unwind&) = delete;
on_unwind& operator=(const on_unwind&) = delete;
~on_unwind() { // noexcept: f must not throw
if (std::uncaught_exceptions() > count_) f_();
}
private:
F f_;
int count_;
};
void add_entry(std::vector<int>& entries, int value, bool fail) {
entries.push_back(value);
on_unwind undo([&entries] { entries.pop_back(); });
if (fail) throw std::runtime_error("validation failed");
}
struct Cleanup {
std::vector<int>& entries;
~Cleanup() { add_entry(entries, -1, false); } // runs in unwinding
};
int main() {
std::vector<int> entries;
try {
Cleanup cleanup{entries};
add_entry(entries, 1, true); // throws: its guard removes the 1
} catch (const std::exception&) {
}
std::printf("%zu entry, value %d\n", entries.size(), entries.back());
}— end example]
Both builds printed 1 entry, value -1. The guard in the first add_entry call recorded 0, saw 1 in its destructor and removed the 1. The second call runs inside ~Cleanup, during unwinding: its guard was made while one exception was already in flight, recorded 1, still saw 1 when its scope ended normally, and kept the -1. The C++98 function, std:: (singular), can only answer “is any exception in flight?”, and inside ~Cleanup the answer is yes. A guard written on it, compiled as C++17, printed 0 entries on both builds: it undid a change that succeeded. That is why C++17 deprecated it and C++20 removed it. Microsoft’s STL warns about it under /std:c++17 (warning STL4006) and no longer declares it under /std:c++20, so both compilers here reject it there, and Clang suggests uncaught_exceptions instead.
The guard’s destructor is noexcept like any other, so the undo must not throw; if it can, you are back at the top of this article. Guards of this kind, scope_exit, scope_fail and scope_success in <experimental/scope>, are specified by the Library Fundamentals TS v3 (ISO/IEC TS 19568:2024). That is an experimental specification, not the standard, which is why the guard above is written by hand. scope_success is a deliberate case of a destructor that may throw: its destructor is noexcept only if the function it calls is, and it calls that function only when its own scope is left normally, never while that scope is being unwound.
Find throwing destructors with clang-tidy
The compiler warnings shown earlier fire on a throw written in the destructor’s body. They miss the more common case, a destructor that calls something that throws:
#include <stdexcept>
void flush_buffers() { throw std::runtime_error("flush failed"); }
struct Flusher {
~Flusher() { flush_buffers(); } // no compiler warning here
};— end example]
Neither MSVC 19.44 nor Clang 19 warned about it, and a program that destroys a Flusher ended with the same exit status as the first one. clang-tidy’s bugprone-exception-escape follows the calls: it checks destructors, move constructors and move assignment operators, swap, main and functions marked noexcept for exceptions that can escape them. clang-tidy 19 flagged ~Flusher above (“an exception may be thrown in function ‘~Flusher’ which should not throw exceptions”). It said nothing when flush_buffers was only declared, with its body in another file; the current documentation describes an option, Treat, for that case, which version 19 does not list. A second check, performance-noexcept-destructor, flags a destructor whose noexcept(expr) evaluates to false, where expr is not the literal false. Neither check reports a destructor written noexcept(false): both documentations treat that as a deliberate choice, so search the code for it. To run both on one file: clang-tidy --checks=. clang-tidy ships with LLVM and is listed on Toolchain with the other analyzers.
For the rest of the core language, dated by standard, see Language.
Questions
Are destructors noexcept by default?
Yes, since C++11. A destructor you write without a noexcept specifier gets the same exception specification as the one the compiler would declare, and both are non-throwing unless the destructor of a base or member is potentially throwing. std:: tells you which one a type has.
What happens if a destructor throws during stack unwinding?
std::terminate is called, whatever the destructor is declared with: noexcept(false) does not help, because C++ cannot have two exceptions leaving the same scope. The default terminate handler calls std::abort, so the program ends there.
How do I report an error from a destructor?
Report it from somewhere else. Give the class a close(), flush() or commit() that throws on failure and call it on the normal path, where the caller can catch it. The destructor then cleans up whatever is left, catches anything that cleanup throws, and logs it.
Is std::uncaught_exception deprecated?
It was deprecated in C++17 and removed in C++20; Microsoft’s STL no longer declares it under /std:c++20. Use std:: (plural, C++17), which returns a count: compare it with the count recorded when the object was made, so that a guard created during unwinding still gets the right answer.
Sources
- Destructorscppreference.com
- [except.terminate] The std::terminate functionC++ standard working draft
- [res.on.functions] Other functionsC++ standard working draft
- std::uncaught_exception, std::uncaught_exceptionscppreference.com