Errors & Exceptions
Catch what you can handle, bubble what you can't.
Errors & Exceptions
Errors are not edge cases. They're a normal part of running a program. Every API call can fail; every file open can return ENOENT; every parse can hit malformed input. How a language handles errors shapes how the whole codebase reads.
Two main schools
There are essentially two ways languages handle errors:
- Exceptions — throw something at the call site, let it propagate up the stack until something catches it. Python, JavaScript, Java, Ruby, C#.
- Result types — return a value that's either success-with-data or failure-with-error. The caller has to look at it. Go (in spirit, via
(value, err)), Rust (Result<T, E>), Haskell (Either).
Each has merits. Exceptions keep the happy path readable; result types make every error visible at the call site.
Throwing and catching
def withdraw(account, amount):
if amount > account.balance:
raise InsufficientFunds(amount, account.balance)
account.balance -= amount
try:
withdraw(account, 100)
except InsufficientFunds as e:
notify_user(f"Need ${e.shortage}")
except Exception as e:
log.error("withdraw failed", e)
finally:
close_session()
Three blocks:
try— code that might raise.except— handler for specific exception types. Caught in declaration order.finally— always runs, raise or no raise. Use for cleanup (closing files, releasing locks).
Exception types matter
Don't catch Exception (or worse, bare except:) unless you know exactly why. You'll silently swallow keyboard interrupts, programming bugs, system errors:
# Bad — masks a typo or memory error as "couldn't fetch user".
try:
user = fetch_user(id)
except Exception:
user = None
Catch the specific type that means "this thing I'm trying might fail in this expected way":
try:
user = fetch_user(id)
except UserNotFound:
user = None
Custom exceptions
Define your own exception types when the built-ins don't carry enough meaning:
class PaymentError(Exception):
"""Base for all payment failures."""
class InsufficientFunds(PaymentError):
def __init__(self, requested, balance):
super().__init__(f"requested {requested}, have {balance}")
self.requested = requested
self.balance = balance
self.shortage = requested - balance
Now the caller can catch PaymentError (anything payment-related) or specifically InsufficientFunds (just one case).
Result types in Go and Rust
Go returns (value, error) and forces you to check:
data, err := os.ReadFile("config.json")
if err != nil {
return fmt.Errorf("read config: %w", err)
}
Rust uses Result<T, E> and the ? operator to propagate:
let data = std::fs::read_to_string("config.json")?;
Both styles make the failure case visible at every step. The trade-off: more lines of code on the happy path.
When to catch, when to bubble
Three rules:
- Catch what you know how to handle. If you can recover (retry, fall back, use a default), catch.
- Bubble what you don't. If you have no recovery plan, let the exception propagate. Some caller higher up the stack might know what to do.
- At the very top, log it and give up. Convert any uncaught exception into a useful 500 / a clean exit / a user-friendly error message.
A function that swallows every exception is a function that hides bugs.
Validation vs exceptions
If you're using exceptions for normal control flow (try/except to test whether a value exists), step back. Most of those have cleaner alternatives:
# Exception as control flow — slow, hard to read.
try:
return d[key]
except KeyError:
return default
# Direct check.
return d.get(key, default)
Exceptions are for exceptional cases. Use the in-language if/in/get-with-default patterns for things you expect to happen routinely.
Cleanup with with / try/finally
Anything that needs cleanup — files, locks, network connections, database transactions — should run inside a context manager:
with open("data.txt") as f:
process(f)
# f.close() called automatically, even if process() raises
let conn;
try {
conn = await pool.acquire();
await use(conn);
} finally {
conn?.release();
}
Forgetting to release a database connection on error is one of the most common production-killer bugs. The with/finally pattern guarantees cleanup.