Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 Exception in signatures, which defeats the type check.
  • Drive widespread wrapping of checked exceptions in RuntimeException subclasses, 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() a FileInputStream. try-with-resources mitigates 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.
  • finally runs 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 in finally, not after catch.
  • Throwing from finally shadows the original exception. This is a known footgun; some teams ban any non-cleanup logic in finally for 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, StopIteration are all routine. Every line of Python is potentially a throw site.
  • The basic guarantee is the floor and the ceiling. GC handles memory. with handles 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:

  1. IDisposable and using. Same procedural-RAII pattern as Java’s try-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.

  1. Async-friendly exception handling. try/catch works across await points, 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.

  2. finally clauses that aren’t first-class but are well-integrated. C#’s try/finally does what you expect, and the language has avoided most of Java’s footguns around finally-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?

  1. Errors are visible in signatures. A function that can fail returns Result. A function that returns a non-Result cannot fail (modulo panics, which are reserved for programmer error). This is closer to Haskell’s effect system than to Java’s checked exceptions, because Result is just a value, not a parallel control flow.

  2. ? makes propagation ergonomic. let x = foo()?; is almost as concise as exception-throwing equivalents, but the propagation is type-checked.

  3. No invisible throw sites. A line of Rust code can panic only if it does an explicit panic!, an unwrap, 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.

  4. std::panic::catch_unwind exists 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.

  5. 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 err instead of throwing does not change the fact that self.balance was decremented before logTransfer failed.
  • 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 != nil checks visible.
  • defer provides RAII for the cases where it works (file close, mutex unlock). Go’s idiomatic use of defer immediately 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/finally for cleanup. There is no using, though there is a TC39 proposal for explicit resource management (using declarations) that is at Stage 3 as of this writing — so by the time you read this, modern JS may have something equivalent to C#’s using.
  • The garbage collector handles memory.
  • For everything else, you are on your own, and your one tool is try/finally.
  • async/await propagates throws across await points, 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

LanguageResource cleanup mechanismException type-checkingCultural posture
C++RAII (deterministic destructors)noexcept opt-inException safety formalized by Abrahams; widely understood, unevenly applied
Javatry-with-resourcesChecked exceptions (declining)Mostly trust GC; two-phase commit on primitives where it matters
C#using (block and declaration)All uncheckedSimilar to Java, more rigorous about async cancellation
Pythonwith, contextlib.ExitStackNoneEAFP — exceptions are routine; basic guarantee is the floor
RustDropResult<T, E> for errorsPanics are programmer-error; mutex poisoning forces acknowledgment
GodeferNone; errors are values“We don’t use panic” — but the underlying problem is identical
JavaScripttry/finally, using (proposed)NoneCasual; relies heavily on GC

What’s common across all of them

Stripped of the syntactic differences, the same problem appears in every language:

  1. A mutation may be partially applied when a non-local control transfer happens.
  2. The mechanism that runs cleanup (destructor, defer, finally, Drop, using, with) only addresses cleanup of registered resources, not preservation of invariants.
  3. 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.