Open a file that doesn’t exist in Python and open() raises a FileNotFoundError that unwinds out of your function. Do the same thing in Go and os.ReadFile hands back an error value sitting next to a nil byte slice. Same failure, two different shapes of code around it. The difference is not cosmetic. It decides whether a caller can ignore the failure by accident, and whether the compiler notices when they do.

What do exceptions and error values actually do differently?

An exception is control flow that jumps. Something fails, the function throws, and execution leaves the current call stack and lands in the nearest matching catch or except block, however many frames up that is. The function’s signature gives no hint that this can happen. loadConfig(path: string): Config in TypeScript looks exactly like a function that can’t fail, until it throws on a missing file.

An error value is data. The function returns it alongside, or instead of, the result it was asked to produce. Go’s (Config, error), Rust’s Result<Config, ConfigError>, and a lookup that returns null on a miss are the same idea: failure is part of the return type, so the caller sees it at the call site. In Rust the compiler makes you deal with it before you touch the result. In Go, convention does.

Neither approach is new, and neither has won. C returned sentinel values like -1 and NULL; exceptions came later, to move error handling out of the main logic. Python, Java, C#, and JavaScript default to exceptions. Go and Rust default to error values. Most languages that default to one still let you reach for the other when it fits better.

How does Go return errors as values?

Go has no exceptions for ordinary failures. A function that can fail returns an error as its last value, and the caller checks it immediately:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package main

import (
	"encoding/json"
	"fmt"
	"os"
)

type Config struct {
	Port int `json:"port"`
}

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

	var cfg Config
	if err := json.Unmarshal(data, &cfg); err != nil {
		return Config{}, fmt.Errorf("parsing config: %w", err)
	}

	return cfg, nil
}

func main() {
	cfg, err := loadConfig("app.json")
	if err != nil {
		fmt.Fprintln(os.Stderr, "startup failed:", err)
		os.Exit(1)
	}
	fmt.Println("listening on port", cfg.Port)
}

%w wraps the original error instead of discarding it, so errors.Is and errors.As further up the stack can check what went wrong without string-matching the message.

The weak point: nothing stops you from writing data, _ := os.ReadFile(path) and throwing the error away. The compiler accepts it. A linter such as errcheck will catch unchecked errors, but only if it’s in your CI.

How do Python exceptions jump to a handler?

The same config loader in Python has no inline error checks after each call. The checking happens around the call, not inside it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import json

class ConfigError(Exception):
    pass

def load_config(path):
    try:
        with open(path) as f:
            data = json.load(f)
    except FileNotFoundError as e:
        raise ConfigError(f"config file not found: {path}") from e
    except json.JSONDecodeError as e:
        raise ConfigError(f"invalid JSON in {path}: {e}") from e

    if "port" not in data:
        raise ConfigError(f"missing 'port' key in {path}")

    return data


try:
    config = load_config("app.json")
except ConfigError as e:
    print(f"startup failed: {e}")
    raise SystemExit(1)

print("listening on port", config["port"])

raise ... from e keeps the original traceback attached to the new ConfigError, the way Go’s %w keeps the original error reachable.

The difference that matters is the signature. load_config never says it can raise. A caller three levels up who forgot the try finds out when the program crashes with a traceback in production, because nothing in the language checks the call site.

How does Rust’s Result force you to handle the error?

Rust keeps the error-as-value idea and adds a type system that won’t let you touch the success value without acknowledging the failure case:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use std::fs;

#[derive(Debug)]
enum ConfigError {
    Io(std::io::Error),
    Parse(serde_json::Error),
}

#[derive(serde::Deserialize)]
struct Config {
    port: u16,
}

fn load_config(path: &str) -> Result<Config, ConfigError> {
    let data = fs::read_to_string(path).map_err(ConfigError::Io)?;
    let config: Config = serde_json::from_str(&data).map_err(ConfigError::Parse)?;
    Ok(config)
}

fn main() {
    match load_config("app.json") {
        Ok(config) => println!("listening on port {}", config.port),
        Err(e) => {
            eprintln!("startup failed: {:?}", e);
            std::process::exit(1);
        }
    }
}

This needs serde (with the derive feature) and serde_json in Cargo.toml.

Result<T, E> is an enum with two variants, Ok(T) and Err(E), and you can’t get a Config out of it without handling both. The ? operator is shorthand for that match: if fs::read_to_string returns Err, load_config returns early with it, mapped into ConfigError::Io. Otherwise it unwraps the Ok and keeps going.

You can still bypass all of this with .unwrap(), which panics on Err. That is Rust’s version of an uncaught exception. Treat it as something to question in code review, not a shortcut.

Why doesn’t fetch() throw on a 404?

The split isn’t only between languages. A single API can use both models for different failures. fetch() rejects its promise on a network failure (DNS didn’t resolve, connection refused) but resolves normally on a 404 or a 500. The HTTP status is a value on the response you already have: you check response.ok, you don’t catch anything. JavaScript Fetch API Error Handling Explained covers why that surprises people who expect an HTTP error to throw, and how to wrap fetch() so your code gets exception-style behavior back if you want it.

When should you use exceptions, and when should you return errors?

Ask one question about the specific failure: does the immediate caller need to act on it, or should it travel up until some boundary handles it?

A cache miss or a validation error on user input is an outcome your code should expect and branch on right there. An error value fits. The caller can’t do its job without reacting, so a return type that makes the failure visible is a help, not ceremony.

A database connection that drops mid-transaction, or an assertion about program state that turns out false, is different. Nothing the immediate caller does will fix it. Letting it propagate as an exception or a panic to one place that logs and shuts down cleanly is less code, and more honest code, than threading it back through every frame.

Languages that default to one model already use the other where it fits. Go panics on an out-of-bounds slice index, because that’s a bug, not a condition the caller should handle. Rust panics on integer overflow in debug builds. Python’s dict.get(key, default) returns a value instead of raising KeyError, for the case where a missing key is expected rather than exceptional.

What goes wrong with each strategy in practice?

Swallowed errors. A bare except: that does nothing, or a Go if err != nil { } with an empty body, erases the failure. The symptom shows up later as missing data or a blank screen, with no error anywhere near the cause. That’s the hard-to-trace flow described in What Is Spaghetti Code and How to Refactor It.

Panics in the wrong goroutine. recover() only works inside the goroutine that panicked. A deferred recover in main won’t save you from a panic in a worker goroutine; the whole program goes down. Goroutines and Channels Explained With Examples shows why errors from concurrent work should come back over a channel, as values.

.unwrap() and broad catch instead of a decision. Both let you stop thinking about the failure case without making it go away. Write a test that forces the error path. That’s much easier when the function returns an error you can construct than when it throws from deep inside a library you don’t own, and it’s the reason error paths get their own tests in Unit vs Integration vs End-to-End Tests Explained.

How to choose per function, not per language

Next time you’re about to write throw or return err, look at the function in front of you. If its caller must react right away, put the failure in the return type: (T, error) in Go, Result in Rust, None or a default in Python. If nothing useful can happen until the failure reaches a top-level handler, raise or panic and handle it once, at that boundary. Then add a test for the error path, whichever you picked.