The Many Ways to Fail: Error Handling Philosophies Across Programming Languages

Every program that has ever run has eventually encountered something unexpected. A file that wasn’t there. A network that dropped the connection. A number that divided by zero. A user who typed their name into a field expecting a date.

What happens next is one of the most revealing design decisions a language can make.

Error handling is not a feature bolted onto a language after the fact. It reflects fundamental beliefs about who programmers are, how reliable they are, and whether the compiler should act as a safety net or stay out of the way. Trace the arc from early FORTRAN to modern Rust, and you’re tracing a decades-long argument about trust, correctness, and what it means to build software that fails gracefully.


Before Error Handling: The GOTO Era

Early FORTRAN had no error handling construct at all. Errors were handled by jumping to a labeled line with GOTO. Something went wrong? Branch to line 9999 and sort it out there.

1
2
3
4
5
6
      READ(5, 100, ERR=9999) X
  100 FORMAT(F10.2)
      Y = 1.0 / X
      GOTO 200
 9999 WRITE(6, *) 'Read error'
  200 CONTINUE

This wasn’t laziness — it was the state of the art. The computational problems FORTRAN was designed to solve (weather prediction, missile trajectories, nuclear physics) were often running on machines where a failed read from a punch card was a genuine edge case, not a routine occurrence. The GOTO gave you an escape hatch. Whether you used it was entirely up to you.

The FORTRAN GOTO is the ancestor of every // TODO: handle this error comment you’ve ever seen in production code. The escape hatch was always there. It just required the programmer to remember to take it.


C: Return Codes and the Honor System

C, arriving in 1972 with ambitions to replace assembly language, formalized the practice of returning special values to signal failure. Functions return -1 on error. Pointer-returning functions return NULL. The global errno variable holds a code indicating what went wrong.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <errno.h>
#include <string.h>

int main() {
    FILE *fp = fopen("config.txt", "r");
    if (fp == NULL) {
        fprintf(stderr, "Error opening file: %s\n", strerror(errno));
        return 1;
    }

    char buffer[256];
    if (fread(buffer, 1, sizeof(buffer), fp) == 0) {
        if (ferror(fp)) {
            fprintf(stderr, "Error reading file\n");
            fclose(fp);
            return 1;
        }
    }

    fclose(fp);
    return 0;
}

The problem is that nothing in C forces you to check the return value. The compiler will not warn you. The program will not crash immediately. It will continue executing with whatever garbage or invalid state the unchecked failure left behind, and the actual failure will manifest somewhere completely unrelated — often in production, often at 2 a.m.

A 2016 study of open-source C code found that error return values are ignored in a substantial fraction of call sites. This is not negligence on the part of C programmers. It is a predictable consequence of making error checking entirely voluntary. When checking an error is optional, it will sometimes be skipped — by beginners who don’t know better, by experienced developers under deadline pressure, and by everyone when writing “quick” code that eventually ships.

C’s philosophy was explicit: the programmer is competent and should not be babied. Forty years of CVEs later, that assumption looks optimistic.


Java’s Checked Exceptions: Forced Handling Goes Wrong

Java, released in 1995, looked at C’s voluntary error checking and decided to fix it with compiler enforcement. The concept: checked exceptions. If a method can throw an exception, it must either handle it or declare it in the method signature. The compiler refuses to compile code that ignores a checked exception.

In theory, this is exactly right. In practice, it produced a different problem.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// What the designers hoped you'd write
public String readConfig(String path) throws IOException {
    try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
        return reader.readLine();
    }
}

// What developers actually wrote to make the compiler stop complaining
public String readConfig(String path) {
    try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
        return reader.readLine();
    } catch (IOException e) {
        // TODO: handle this
        return null;
    }
}

// Or this
public String readConfig(String path) throws Exception {
    // Now the caller's problem
}

The empty catch block and the throws Exception signature became widespread patterns. Developers were not ignoring error handling because they were lazy — they were writing just enough code to satisfy the compiler while deferring the actual problem. A throws Exception declaration propagates the burden upward without providing any information about what could go wrong. An empty catch silently swallows failures.

Checked exceptions are now widely considered a failed experiment in their original form. Bruce Eckel, the author of Thinking in Java, turned against them. Anders Hejlsberg, the lead designer of C#, rejected them entirely when designing that language. They add verbosity without adding safety. You can satisfy the compiler without actually handling anything.

The lesson Java taught the industry: forcing developers to acknowledge errors is not the same as forcing them to handle errors correctly.


Go: Back to Return Codes, With a Twist

When Google designed Go in 2007 (publicly announced in 2009), they were aware of Java’s checked exception debacle. Their solution was a deliberately humble one: go back to return codes, but make them idiomatic by using multiple return values.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import (
    "fmt"
    "os"
)

func readConfig(path string) (string, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return "", fmt.Errorf("reading config: %w", err)
    }
    return string(data), nil
}

func main() {
    content, err := readConfig("config.txt")
    if err != nil {
        fmt.Fprintf(os.Stderr, "error: %v\n", err)
        os.Exit(1)
    }
    fmt.Println(content)
}

The idiomatic Go pattern — value, err := doSomething() followed by if err != nil { return nil, err } — is explicit and readable. Errors are values. They can be inspected, wrapped, and passed around. Nothing about the model is surprising.

What is surprising is how often the check gets skipped. A 2020 study of Go code found significant rates of unchecked errors — developers assigning to _ or simply not capturing the error return at all. Go improved on C in that errors are at least returned alongside values rather than through a global side channel, but the fundamental issue remains: the language does not force you to act on an error, only to receive it.

Go made a deliberate choice. Exceptions felt like “hidden control flow” to the designers, and they valued simplicity and readability over compiler-enforced correctness. The resulting pattern is easy to learn and easy to follow — and easy to skip when you’re in a hurry.


Haskell: Encoding Failure in the Type System

Before examining Rust, it helps to understand where Rust borrowed its ideas from. Haskell, which originated in the late 1980s (the first Haskell Report was published in 1990), solved error handling by making the possibility of failure explicit in the type of a function’s return value.

Haskell’s Maybe type represents a computation that might produce nothing:

1
2
3
safeDiv :: Int -> Int -> Maybe Int
safeDiv _ 0 = Nothing
safeDiv x y = Just (x `div` y)

The Either type represents a computation that produces either a success value or an error:

1
2
3
4
readConfig :: FilePath -> Either String String
readConfig path = ...
-- Left "File not found: config.txt"   (failure)
-- Right "key=value\n..."              (success)

The compiler enforces that you handle both cases before using the value. You cannot call functions on a Maybe Int as though it were an Int — the types are different. The do notation makes chaining these operations readable:

1
2
3
4
5
processConfig :: FilePath -> Either String Int
processConfig path = do
    contents <- readConfig path
    value    <- parseValue contents
    validate value

If any step returns Left, the computation short-circuits and propagates the error. This is not exception handling — there is no stack unwinding, no runtime mechanism. It is pure type-system discipline. The compiler guarantees that unhandled failures are a type error, not a runtime surprise.


Rust: The Pragmatic Synthesis

Rust, which released its 1.0 in 2015, took Haskell’s Either type and made it the idiomatic error handling mechanism for a systems programming language. The Result<T, E> type is an enum with two variants: Ok(T) for success, Err(E) for failure.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use std::fs;

fn read_config(path: &str) -> Result<String, std::io::Error> {
    let contents = fs::read_to_string(path)?;
    Ok(contents)
}

fn main() {
    match read_config("config.txt") {
        Ok(contents) => println!("{}", contents),
        Err(e) => eprintln!("Error: {}", e),
    }
}

The match forces you to handle both variants. You cannot call string methods on a Result<String, Error> — the compiler will not allow it. You must first unwrap the success case, which requires acknowledging the error case.

Early Rust code required explicit match statements everywhere, which critics found verbose. The language designers recognized that making the correct approach this painful was a problem — developers would reach for unwrap() (which panics on error, roughly equivalent to ignoring it) just to avoid the ceremony.

The answer was the ? operator, stabilized in Rust 1.13 in 2016. In a function that returns Result, ? automatically propagates errors up the call stack:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use std::fs;

fn read_config(path: &str) -> Result<String, std::io::Error> {
    // Without ? operator:
    // let contents = match fs::read_to_string(path) {
    //     Ok(c) => c,
    //     Err(e) => return Err(e),
    // };

    // With ? operator — identical semantics, one character:
    let contents = fs::read_to_string(path)?;
    Ok(contents)
}

The ? operator is a concrete example of syntax added specifically to make the correct approach less painful. Before ?, Rust error propagation required explicit match statements at every call site. After ?, Result-based error handling became ergonomically close enough to exception-based handling that it became the idiomatic default — not because developers were forced into it, but because the friction had been reduced to near zero.

The result is a language where the “happy path” (using ? freely) and the “correct path” (handling all errors) are the same path.


Python: Forgiveness Over Permission

Python takes a fundamentally different view. Its philosophy — sometimes called EAFP, “Easier to Ask Forgiveness than Permission” — says that you should just try the operation and handle whatever goes wrong:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def read_config(path):
    try:
        with open(path, 'r') as f:
            return f.read()
    except FileNotFoundError:
        print(f"Config file not found: {path}")
        return None
    except PermissionError:
        print(f"Permission denied: {path}")
        return None

Python has no compiler to enforce anything. Exceptions are unchecked — you may catch them or not. The language bets that explicit try/except blocks, combined with documentation and convention, produce readable code without the ceremony of checked exceptions.

The EAFP style is often more readable than the alternative (checking every precondition before attempting the operation), and Python’s dynamic nature makes compile-time enforcement impractical anyway. The trade-off is familiar: freedom from boilerplate, in exchange for runtime surprises when someone skips the try.


Erlang: Just Let It Crash

All of the approaches above share a common assumption: when something goes wrong, the function or program should handle it and continue. Erlang questions that assumption at a fundamental level.

Erlang processes are cheap — you can spawn thousands of them. Each process is isolated: it has its own memory, its own state, and its own fate. One crashing process cannot corrupt another’s state, because they share nothing. Erlang’s OTP framework includes supervisor processes whose entire purpose is to watch other processes and restart them when they fail.

In this model, the correct response to an unexpected error is often to let the process crash and restart clean:

1
2
3
4
5
6
7
8
9
% A simple file reading process in Erlang
read_config(Path) ->
    case file:read_file(Path) of
        {ok, Contents} ->
            {ok, Contents};
        {error, Reason} ->
            % In a supervised process: just crash and let the supervisor restart
            error({config_read_failed, Path, Reason})
    end.

This only works because Erlang’s process isolation means that a crash is contained. In a language with shared mutable state — C, Java, Python — letting a component crash might leave shared data structures in an inconsistent state, corrupting the entire program. In Erlang, each process’s state is its own, so a restart genuinely means starting fresh.

Erlang’s “let it crash” philosophy is not an excuse to ignore errors. It is a recognition that defensive programming against every possible failure mode is often futile, and that a clean restart from a known-good state is frequently the most reliable recovery strategy. The philosophy has influenced Elixir, Akka (in the JVM world), and the broader field of resilient system design.


Swift: A Hybrid That Learns From Java

Swift, which Apple introduced in 2014, faced the same design choice Java faced in 1995 and reached a different conclusion. Swift 2.0 (2015) introduced a throws/try mechanism superficially similar to Java’s checked exceptions, but with one crucial difference: no distinction between checked and unchecked exceptions.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import Foundation

func readConfig(at path: String) throws -> String {
    do {
        return try String(contentsOfFile: path, encoding: .utf8)
    } catch {
        throw error
    }
}

do {
    let contents = try readConfig(at: "config.txt")
    print(contents)
} catch {
    print("Error: \(error)")
}

Functions that can fail are marked throws. Callers use try. The compiler ensures you acknowledge that a call can throw — but it does not require you to handle every specific error type, eliminating the combinatorial explosion of Java’s checked exception signatures.

Swift also provides Result<Success, Failure> (introduced in Swift 5.0) for functional-style chaining, giving developers a choice between the imperative try/catch style and the monadic style depending on what reads more clearly in context.


The Philosophical Divide

Step back from the syntax, and you see three distinct philosophies:

Trust the developer. C and Go return errors as values and rely on programmers to check them. This produces concise, explicit code when done well — and silent failures when done poorly. It reflects a belief that experienced developers working in good faith will do the right thing, and that compiler enforcement creates more friction than it prevents bugs.

Enforce correctness. Rust and Haskell encode failure in the type system, so unhandled errors are compile-time errors rather than runtime surprises. This reflects a belief that humans are reliably unreliable under pressure, and that making the correct approach easy (via the ? operator) is more effective than relying on discipline. The cost is learning curve and occasional verbosity; the benefit is a class of bugs that simply cannot reach production.

Take a different architecture. Erlang’s “let it crash” model sidesteps the question of how to handle errors in place by making clean restarts cheap and safe. This isn’t applicable everywhere — it requires the process isolation model — but where it applies, it produces systems that degrade gracefully rather than accumulating inconsistent state.

There is no universally correct answer. The right model depends on what failure means in your domain.

For C embedded in a pacemaker, silent failure is not acceptable and defensive programming is mandatory. For a Go microservice behind a load balancer, a clean error return and a retry from the caller is often the right response. For a Rust library that will be used in security-critical contexts, compiler-enforced exhaustive error handling prevents an entire category of vulnerabilities. For an Erlang telephony system handling millions of calls, letting individual calls crash and restart is more reliable than trying to handle every possible failure in-place.

The languages that handle errors well are not those that make it impossible to write bad error handling code. They are the ones that make it easier to write good error handling code than to write nothing at all.


Explore the Languages

Each of the languages in this article is documented on CodeArchaeology with runnable examples:

  • RustResult<T, E> and the ? operator
  • Go — Multiple return values and explicit error checking
  • Java — Checked exceptions and their evolution
  • Python — EAFP and the try/except model
  • HaskellEither and Maybe in the type system
  • Erlang — Process isolation and supervisor trees
  • Swiftthrows/try without checked exception complexity
  • C — Return codes and the honor system

Or explore our encyclopedia of 1,200+ programming languages to see how the full breadth of language design history has approached the question of what to do when things go wrong.


What’s the worst error-handling bug you’ve encountered in production? Share your archaeology stories on GitHub.

Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining