Skip to content

Error handling

Why it matters

Every language picks one of two ends of a spectrum: errors as values (Go's error, Rust's Result<T, E>, OCaml's result) or exceptions (Java, Python, C++, JavaScript). The end your language picked shapes every API design decision you make for the next ten years.

The "values" camp says: failure is a return type; treat it like any other data. The "exceptions" camp says: failure is exceptional control flow; let it propagate up and handle at the boundary. Both work. Both produce bad code in the wrong hands. The skill is reading and writing both styles fluently.


The lens, per path

Go - errors as values, error interface, panic for unrecoverable

Month 1 - Runtime Foundations. The single most-recognized Go idiom: if err != nil { return err }. The error interface has exactly one method: Error() string.

Modern (1.13+) Go adds errors.Is (equality with sentinel errors), errors.As (type-assertion on wrapped chains), fmt.Errorf("doing X: %w", err) (wrapping), errors.Join (multi-error). panic exists for unrecoverable bugs (nil deref, out-of-bounds, programmer error); recover lets a framework catch it at goroutine boundaries.

What's unique here: the discipline of enumerating failure modes at the type level. A Go function's signature is its honest documentation: every error it can return is in the return type. Compare to Python where a function can throw anything from anywhere.

The trap

if err != nil { return err } without wrapping loses context. Modern Go: return fmt.Errorf("reading config: %w", err). The reader of the eventual error message knows where it came from.

Rust - Result<T, E>, ? operator, panic! for invariants

Month 1 - Foundations, then Month 2 - Type System for ?, From, error-trait machinery.

Result<T, E> is a sum type: Ok(T) or Err(E). The ? operator desugars to match expr { Ok(v) => v, Err(e) => return Err(From::from(e)) }. Common error types: Box<dyn Error> (simple), thiserror (typed, for libraries), anyhow (untyped, for binaries).

panic! is for invariant violations - bugs, not failures. The default behavior is unwind-the-stack-and-abort; panic = "abort" in Cargo.toml switches to immediate abort. Catchable at thread boundaries with std::panic::catch_unwind.

What's unique here: the typed error story. A function's Result<T, MyError> enumerates every failure mode in the type system; the compiler ensures every variant is handled or explicitly propagated. Most concurrency-class bugs (data races) and many error-handling bugs become compile errors.

The trap

.unwrap() everywhere in non-test code. Useful for prototyping, terrible in production - a single unwrap on an unexpected None/Err panics the program. Use ? and proper error types instead; reserve unwrap for places where you've already proven the value can't be Err.

Java - checked + unchecked exceptions, try-with-resources

Month 1 - Language & Toolchain, then Appendix B - Legacy Java for the genealogy.

Two exception families: checked (extend Exception, must be declared in throws or caught) and unchecked (RuntimeException, no declaration required). Modern Java practice: use checked exceptions for recoverable I/O at boundaries, unchecked for programming errors, never throws Exception. The Lombok @SneakyThrows annotation throws checked exceptions as unchecked - a feature you should suspect anyone using.

Try-with-resources (Java 7+): try (var conn = open()) { ... } auto-closes any AutoCloseable. Suppresses secondary exceptions from close() attached to the primary.

What's unique here: the only mainstream language with checked exceptions in the type system. Whether that's a feature or a bug is the longest-running debate in the language community.

The trap

catch (Exception e) { e.printStackTrace(); } is the most-shipped Java anti-pattern. Always: contextual log + rethrow OR contextual log + explicit fallback. Never silently swallow.

Python - exceptions, EAFP, context managers

Month 2 - Intermediate Idioms. Python is exceptions-only, and the cultural convention is EAFP (Easier to Ask Forgiveness than Permission): try the operation, catch the exception. Compare to LBYL (Look Before You Leap), the C-style "check then do" pattern.

with statement + context managers (__enter__/__exit__) is Python's try-with-resources. Async variant: async with. Generators and async-generators support context-manager semantics too.

Modern (3.11+) Python adds exception groups (ExceptionGroup, except* syntax) and exception notes (exc.add_note(...)) to handle multiple concurrent failures (asyncio tasks, structured concurrency).

What's unique here: the casualness of exceptions. StopIteration is exception-as-control-flow for iteration. KeyError is the canonical "not found." ValueError and TypeError are domain-style errors. The exception system is the substrate for huge chunks of normal-path code.

The trap

except: (bare except) catches SystemExit, KeyboardInterrupt, and BaseException. Always except Exception: (or narrower). Otherwise Ctrl-C doesn't work and you confuse the runtime.

Linux kernel - errno, ERR_PTR, WARN_ON, BUG_ON

Month 1 - Kernel Foundations. Kernel C doesn't have exceptions. Errors propagate as negative errno return values (-ENOMEM, -EINVAL, ...) or via the ERR_PTR(-errno) trick for functions returning pointers (use IS_ERR(p) ? PTR_ERR(p) : 0 to unwrap).

For programmer errors: WARN_ON(cond) (loud, doesn't crash, taints kernel), BUG_ON(cond) (loud, crashes the kernel - only for invariants that absolutely cannot be true). panic("...") for unrecoverable situations.

What's unique here: the kernel cannot retry, cannot ask the user, cannot crash most paths. Error handling is GFP flags for "what should this allocator do under pressure", reference counting for "who owns this object", and a meticulous "unwind on failure" pattern at every multi-step setup.

The trap

Forgetting to undo partial setup on failure. Kernel idiom is goto unwind labels - err_free_foo:, err_release_bar: - labeling each stage of cleanup. This is the correct use of goto.


The contrasts that teach

Aspect Go Rust Java Python Kernel
Model values values (sum types) exceptions (checked + unchecked) exceptions negative errno, ERR_PTR
Propagation manual return err ? operator throws (checked) or implicit (unchecked) implicit manual return -err
Compile-time enforcement only via tools (errcheck) yes - Result must be used partial (checked only) none none (review-only)
Recovery / cleanup defer Drop trait (RAII) try-with-resources with / context managers goto unwind labels
"Bug" channel panic (recoverable in goroutines) panic! (unwind or abort) Error, OutOfMemoryError BaseException subclasses BUG_ON, panic("...")
Cultural default wrap and return propagate with ?, log at boundary catch at boundary EAFP, narrow except unwind, log, return

The most clarifying read: Go's errors.Is/As/Wrap + Rust's ? + thiserror side-by-side. Two takes on the same insight - failure is a value to be enumerated - with different ergonomic affordances.


What to read first

  • You write any Go → Go Month 1, then read the errors package source. Internalize the %w wrapping convention.
  • You write any Rust → Rust Month 1 + Month 2's ? section. Then pick thiserror for libraries, anyhow for binaries; switching mid-project is painful.
  • You write any Java → Java Month 1 + Appendix B (legacy). Decide your team's stance on checked exceptions and document it.
  • You write any Python → Python Month 2's exceptions section. Then learn ExceptionGroup (3.11+) before you write any async code.
  • You hack the kernel → Linux Month 1's error-handling section. Read 10 random drivers/ files until the goto err_* pattern is automatic.