Exception Safety Across Languages
The previous chapters used C++ as the canonical battleground for two reasons. First, C++ is where the formal vocabulary was developed, and where the design trade-offs are most explicit. Second, C++’s deterministic destruction makes the mechanics visible: you can see, line by line, what runs when. In every other mainstream language, the same problem exists, but the mechanism is hidden behind syntactic sugar or absent entirely, which makes the problem easier to ignore and harder to fix.
This chapter is a tour. Each language gets a section: how exception handling actually works there, what guarantees the language and standard library promise, and where the local culture has decided to look the other way.
Java: checked exceptions and the schism
Java’s distinguishing feature, and the source of its exception-safety neurosis, is checked exceptions. The type system tracks the set of exceptions a method can throw, and callers must either catch them or declare them in their own signatures. The intent was to make error paths visible at the type-system level, the way Haskell makes effects visible.
The reality, after twenty-five years, is that checked exceptions:
- Force callers to handle errors at the wrong abstraction level (the immediate caller is rarely the right place to handle a
SQLException). - Bleed implementation details through abstractions (changing a method to use a database means changing every method up the call stack to declare
throws SQLException). - Drive widespread
throws Exceptionin signatures, which defeats the type check. - Drive widespread wrapping of checked exceptions in
RuntimeExceptionsubclasses, which also defeats the type check.
C# explicitly chose not to have checked exceptions, citing this experience. Newer JVM languages (Kotlin, Scala) have abandoned them. Java itself has effectively abandoned them in idiomatic code: Stream, CompletableFuture, and the java.util.function interfaces all wrap checked exceptions, because they had no choice — Function<T, R> cannot have a throws clause without being parameterized over the exception type, which Java’s generics cannot easily express.
The exception-safety question in Java is therefore: given that nearly all exceptions are unchecked, how do you reason about partial state on throw?
The honest answer is that Java code is usually basic-guarantee by accident. The garbage collector handles the leak side: any object allocated and then orphaned by a throw will be reclaimed, eventually. There are no destructors, so resources held outside memory require try-with-resources (Java 7+) or explicit try/finally:
try (Connection conn = pool.acquire();
PreparedStatement stmt = conn.prepareStatement(sql)) {
// use stmt
} // close() called on stmt and conn in reverse order, even on throw
This is procedural RAII: a syntactic form that the compiler turns into try/finally. It works for resources that implement AutoCloseable. It does not work for invariants that aren’t tied to a closeable.
For invariant preservation, Java code typically follows the same patterns we covered in C++:
public void transferTo(Account other, long amount) {
long newSelf = this.balance - amount;
long newOther = other.balance + amount;
audit.logTransfer(amount, other); // can throw
this.balance = newSelf; // primitive assignment, can't throw
other.balance = newOther;
}
Two-phase commit translates directly: long assignment is atomic and exception-free, so once the throwing operation is past, the rest is no-throw.
Where Java differs from C++ in important ways:
- No deterministic destruction means you cannot encode “I own this resource” at the type level. The compiler will not enforce that you
close()aFileInputStream.try-with-resourcesmitigates this for narrow scopes, but does not for fields of a long-lived object. - Exception chaining (
Throwable.getCause()) is good and used widely. Wrapping a low-level exception in a higher-level one without losing the trace is idiomatic. finallyruns after the catch. This is a small thing that bites people; resource cleanup that should happen before the higher-level handler decides what to do has to be infinally, not aftercatch.- Throwing from
finallyshadows the original exception. This is a known footgun; some teams ban any non-cleanup logic infinallyfor this reason.
The local culture is to mostly trust that the basic guarantee is provided by GC + try-with-resources, to provide the strong guarantee where it matters by two-phase-commit on primitive fields, and to mostly not think about it otherwise. This works most of the time. The cases where it doesn’t work tend to involve mutable shared state across threads — see chapter 7.
Python: EAFP and the cost of pretending
Python’s culture summarizes itself as Easier to Ask Forgiveness than Permission. The idiomatic pattern is to attempt the operation and catch the exception if it fails, rather than checking preconditions in advance.
try:
value = d[key]
except KeyError:
value = default
vs.
if key in d:
value = d[key]
else:
value = default
The EAFP version is preferred in Python style guides. It’s also slightly more correct under concurrent mutation (no TOCTOU between the check and the use), and slightly faster in the common case. The point is that exceptions are not exceptional in Python — they are routine flow control for a wide class of operations: dict lookups, attribute access, file I/O, type coercion.
This has consequences for exception safety:
- Python code throws constantly.
AttributeError,KeyError,TypeError,ValueError,StopIterationare all routine. Every line of Python is potentially a throw site. - The basic guarantee is the floor and the ceiling. GC handles memory.
withhandles closeable resources:
with open(path) as f:
data = f.read()
- Beyond this, you are on your own. Python provides no destructors with deterministic timing (
__del__exists but runs at GC time, which is non-deterministic and forbidden during interpreter shutdown). - The strong guarantee is the user’s problem. There is no syntactic support, no library convention, no idiom. If you want it, you write two-phase commit by hand.
A specific Python pitfall: __init__ that fails partway leaves the object partially constructed and referenced by self, which means it can be observed if you do something silly like assign it before init finishes. The standard pattern is to do all validation before mutation, which is good advice in any language.
Python’s contextlib provides ExitStack, which is the closest thing the language has to scope guards:
from contextlib import ExitStack
def update_two_files():
with ExitStack() as stack:
a = stack.enter_context(open('a', 'w'))
a.write('new content')
stack.callback(restore_a)
b = stack.enter_context(open('b', 'w'))
b.write('new content')
stack.callback(restore_b)
# both succeeded
stack.pop_all().close() # dismiss; nothing rolls back
This is good and underused. The standard library also provides contextlib.suppress, contextlib.contextmanager, and contextlib.closing — the building blocks of exception-safe Python — and you should know them by heart if you write Python in production.
The specific cost of Python’s culture: because exceptions are routine, every Python function is implicitly the middle of an exception path. The basic guarantee for arbitrary Python code is thus a stronger claim than the basic guarantee for, say, Java code that uses checked exceptions narrowly. Python programmers pay this cost in defects that look like “the cache got into an inconsistent state somehow.” The fix is the same as everywhere else: identify the throwing operations, identify the mutations, and order them.
C#: using and IDisposable, and a more honest exception story
C# took the lessons of Java’s checked-exceptions experiment and chose not to repeat them. All exceptions in C# are unchecked. The language compensates with three things:
IDisposableandusing. Same procedural-RAII pattern as Java’stry-with-resources, but introduced earlier and more deeply embedded:
using (var conn = pool.Acquire())
using (var stmt = conn.PrepareStatement(sql)) {
stmt.Execute();
}
C# 8 introduced using declarations (without the parenthesized scope), which behave like Go’s defer — cleanup at end of enclosing block:
public void DoStuff() {
using var conn = pool.Acquire();
using var stmt = conn.PrepareStatement(sql);
stmt.Execute();
} // both disposed here, in reverse order
This is the closest mainstream syntactic match to C++’s RAII, and it’s used heavily.
-
Async-friendly exception handling.
try/catchworks acrossawaitpoints, with the runtime handling the trampolining. This is non-trivial — the call stack at the catch site is a logical async stack, not the physical one — and C# does it transparently. Other languages have struggled with this; we’ll come back to it in chapter 7. -
finallyclauses that aren’t first-class but are well-integrated. C#’stry/finallydoes what you expect, and the language has avoided most of Java’s footguns aroundfinally-shadowing.
C# code’s exception-safety story is otherwise close to Java’s: GC handles leaks, using handles closeable resources, two-phase commit handles invariants where it matters, and most code provides the basic guarantee accidentally. The cultural difference is that C# shops more often have explicit conventions about asynchronous cancellation (which is exception-shaped, even when modeled as OperationCanceledException), because the .NET ecosystem has been more rigorous about cancellation tokens than the JVM has been about its various interruption mechanisms.
Rust: the deliberate avoidance of unwinding
Rust has unwinding. Rust does not want you to use it. This is not a contradiction; it is a design choice.
panic! is Rust’s analog of throwing. It triggers stack unwinding (by default; you can compile with panic = "abort" to skip unwinding entirely), which runs Drop implementations on the way up — Rust’s RAII. So the mechanism is there, and Drop provides exception-safe cleanup of resources by exactly the same mechanism as C++ destructors.
But: idiomatic Rust does not panic for recoverable errors. It returns Result<T, E>. The type system enforces this — Result is the conventional error-return type, the ? operator propagates it cheaply, and panic is reserved for “the program has reached a state it cannot meaningfully continue from.”
What does this buy you?
-
Errors are visible in signatures. A function that can fail returns
Result. A function that returns a non-Resultcannot fail (modulo panics, which are reserved for programmer error). This is closer to Haskell’s effect system than to Java’s checked exceptions, becauseResultis just a value, not a parallel control flow. -
?makes propagation ergonomic.let x = foo()?;is almost as concise as exception-throwing equivalents, but the propagation is type-checked. -
No invisible throw sites. A line of Rust code can panic only if it does an explicit
panic!, anunwrap, an indexed access (which can panic on out-of-bounds), an arithmetic operation that overflows in debug mode, or calls a function that does one of those. The first four are visually obvious; the fifth requires reading. -
std::panic::catch_unwindexists for the cases where you must contain a panic — typically at FFI boundaries, where a panic crossing into C code is undefined behavior. It is intentionally awkward, because you should not use it as a general exception-handling mechanism. -
Library code is generally written assuming
panic = "abort". That is, library authors are encouraged to provide the basic guarantee (no double-frees, no use-after-free, no UB) under panic, but not the strong guarantee for operation atomicity. The expectation is that users who want strong-guarantee semantics build them out of explicit transaction types, not out of panic-recovery.
The interesting case in Rust is poisoning. If a thread panics while holding a Mutex, the mutex is poisoned — subsequent attempts to lock it return Err(PoisonError). This is Rust admitting, in the type system, that exception safety in the presence of shared state is hard, and forcing the user to acknowledge the possibility that the protected state is inconsistent. You can recover from poison (.into_inner()), but the language makes you say so explicitly.
This is, in the author’s view, the most honest design choice any mainstream language has made about exception safety. Most languages let you silently ignore the possibility of inconsistent state after a panic. Rust makes you write code that names it.
Go: panic, recover, and pretending
Go has panic and recover. Go programmers, by strong convention, do not use them.
The official line — restated repeatedly by the Go team — is that panic is for unrecoverable errors and programmer mistakes. Recoverable errors are returned as values, with the famous if err != nil ceremony at every step. recover is provided so that a server’s request handler can survive a panic in user code without crashing the whole process, and basically not for any other purpose.
This works, in a Go-shaped way. The if err != nil pattern is verbose but makes every error path visible. The defer statement provides procedural RAII: any defer’d call runs at function exit, including on panic.
func transferTo(self *Account, other *Account, amount int) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("transfer panicked: %v", r)
}
}()
self.balance -= amount
if err := logTransfer(amount, other); err != nil {
self.balance += amount // manual rollback
return err
}
other.balance += amount
return nil
}
A few things to notice:
- The exception-safety problem from chapter 1 is exactly the same in Go. Returning
errinstead of throwing does not change the fact thatself.balancewas decremented beforelogTransferfailed. - The fix is exactly the same: either reorder so the throwing operation is first, or do manual rollback. Go’s lack of exceptions does not make this disappear; it just makes the
err != nilchecks visible. deferprovides RAII for the cases where it works (file close, mutex unlock). Go’s idiomatic use ofdeferimmediately after acquiring the resource is the syntactic equivalent of constructing an RAII object.- The Go community’s relative indifference to exception-safety vocabulary is, in my view, a side effect of believing that “no exceptions” means “no exception-safety problem.” It does not.
A specific Go pitfall: panic in a deferred function will replace the original panic. This is sometimes useful, often dangerous, and the rules for what recover returns in nested deferreds are subtle enough that production Go code generally avoids any panic logic that can’t be expressed as the canonical “wrap a request handler in a recover.”
JavaScript: a cautionary tale
JavaScript has try/catch/finally, throws everything from any line, has no destructors, and no real concept of resource ownership. The story for exception safety is roughly:
- Use
try/finallyfor cleanup. There is nousing, though there is a TC39 proposal for explicit resource management (usingdeclarations) that is at Stage 3 as of this writing — so by the time you read this, modern JS may have something equivalent to C#’susing. - The garbage collector handles memory.
- For everything else, you are on your own, and your one tool is
try/finally. async/awaitpropagates throws acrossawaitpoints, similar to C#. Exceptions thrown in async functions become rejected promises, which is occasionally confusing.- Unhandled promise rejections used to silently fail; modern runtimes warn loudly about them.
The single most important pattern in async JavaScript for exception safety is Promise.all / Promise.allSettled — the difference being whether one rejection cancels the others or all are awaited. If you launch parallel async operations and one throws, you almost always want Promise.allSettled and explicit handling, because Promise.all’s behavior of “first rejection wins, others run to completion but their results are discarded” is rarely what you wanted, and the discarded results may have side effects you weren’t planning to deal with.
JavaScript’s culture around this is, charitably, casual. The basic guarantee is provided by the GC and try/finally. The strong guarantee is rare enough that most code does not pretend to provide it. Production bugs that look like “the cache state got weird after that one error in 2017” are common, and almost always trace to half-completed mutations on an exception path.
A small comparison table
| Language | Resource cleanup mechanism | Exception type-checking | Cultural posture |
|---|---|---|---|
| C++ | RAII (deterministic destructors) | noexcept opt-in | Exception safety formalized by Abrahams; widely understood, unevenly applied |
| Java | try-with-resources | Checked exceptions (declining) | Mostly trust GC; two-phase commit on primitives where it matters |
| C# | using (block and declaration) | All unchecked | Similar to Java, more rigorous about async cancellation |
| Python | with, contextlib.ExitStack | None | EAFP — exceptions are routine; basic guarantee is the floor |
| Rust | Drop | Result<T, E> for errors | Panics are programmer-error; mutex poisoning forces acknowledgment |
| Go | defer | None; errors are values | “We don’t use panic” — but the underlying problem is identical |
| JavaScript | try/finally, using (proposed) | None | Casual; relies heavily on GC |
What’s common across all of them
Stripped of the syntactic differences, the same problem appears in every language:
- A mutation may be partially applied when a non-local control transfer happens.
- The mechanism that runs cleanup (destructor,
defer,finally,Drop,using,with) only addresses cleanup of registered resources, not preservation of invariants. - The strong guarantee, where you want it, requires the same patterns: separate the throwing work from the mutating work, and arrange for the mutation to be no-throw at the moment of commit.
If you internalize the patterns from chapters 1–4, you can apply them in any of these languages. The syntax changes; the structure does not. This is the value of the formal vocabulary: it is portable.
The next chapter introduces a system that, I will argue, is genuinely different — strictly more powerful than any of the above — and that almost nobody uses.
Further reading
- Anders Hejlsberg interview, “The Trouble with Checked Exceptions,” 2003 — the C# designer’s case against Java’s choice. https://www.artima.com/articles/the-trouble-with-checked-exceptions
- Effective Java by Joshua Bloch, items 49–77 (the exceptions chapter). The standard treatment for Java idioms.
- The Rust Programming Language, chapter 9 (“Error Handling”). Specifically the panic-vs-Result section.
- Steve Klabnik, “The Rust Panic Hooks,” for the recovery-at-FFI-boundary use case. Also the Rust Reference’s section on
std::panic::catch_unwind. - “Errors are values,” Rob Pike, https://go.dev/blog/errors-are-values. The Go team’s stated position.