When something can go wrong in a function, you have two ways to tell the caller: return an error value, or throw an exception. The choice is one of those debates that has persisted across decades and languages, and the right answer depends on your language, your domain, and the kind of error.
This post is the practical considerations that drive the decision — not the philosophical ones — and how the modern language landscape has shaped where each fits.
What Each Choice Says
Return error codes say: "this might fail, here is how, you must handle it." The error is part of the function's normal return value.
Throw exceptions say: "this normally succeeds; if something exceptional happens, control transfers to whoever is prepared for it." Exceptions move responsibility for handling up the call stack.
The vocabulary matters: "exceptional" implies "should not normally happen." Errors that are part of the contract — file not found, validation failed, payment declined — are not really exceptional. They are expected outcomes.
The Language Defaults
Modern languages have strong defaults that shape what is idiomatic:
- Go. Error returns. Every function that can fail returns
(value, error). Exceptions exist (panic) but are reserved for truly unrecoverable conditions. - Rust. Result types. Functions return
Result<T, E>. The compiler forces the caller to handle the error case. - Java. Checked exceptions. Methods declare what they throw. Callers must handle or re-declare.
- C# / Python. Unchecked exceptions. Methods can throw anything, callers can catch or ignore.
- JavaScript / TypeScript. Unchecked exceptions, but
Promise.rejectand async error handling complicate things. - PHP. Unchecked exceptions, with growing convention to use exceptions for errors.
If you are writing idiomatic code in a language, the language usually picks for you. Fighting the convention adds friction without much benefit.
When Error Returns Win
The error is expected and recoverable.
file, err := os.Open(filename)
if err != nil {
return defaultConfig, nil // fallback
}
defer file.Close()
Most callers want to handle the "file not found" case. The error is part of the function's contract, not exceptional. Returning the error makes that explicit.
You want the type system to force handling.
let result = parse_input(s); // Result<Output, ParseError>
match result {
Ok(output) => process(output),
Err(e) => return Err(e), // must handle
}
The compiler refuses to ignore the error. For domains where missed errors are catastrophic — financial code, embedded systems — this is exactly what you want.
The error path is performance-critical.
Exception handling in many languages is slower than the normal return path, especially when exceptions are thrown often. For high-frequency code where errors are common (parsing, validation), error returns are typically faster.
Errors are not exceptional.
A "user not found" lookup is not exceptional in a system that gets millions of lookups, most of which will succeed. Returning nil (or an option type) makes the absence ordinary.
When Exceptions Win
The error is truly exceptional.
function divide(int $a, int $b): float
{
if ($b === 0) {
throw new DivisionByZeroError();
}
return $a / $b;
}
A division by zero is a bug, not a normal outcome. Throwing forces the caller to deal with it consciously — and most callers do not deal with it, which is fine for a bug.
The error can cross many layers without anyone caring.
function processOrder(Order $order): void
{
$charge = $this->paymentApi->charge($order); // can throw
$this->inventory->reserve($order); // can throw
$this->shipping->schedule($order); // can throw
$this->notifications->send($order); // can throw
}
Each step can fail in ways the top-level handler responds to identically: log the error, return a 500. Threading error returns through every call site adds noise without value.
The language idiom expects it.
In Python, raising ValueError for invalid inputs is what every developer expects. Returning (value, error) from a Python function is jarring. Match the idiom unless you have a specific reason not to.
The Half-Bad Middle Ground
Some patterns try to combine both and end up with the disadvantages of each.
Returning null for failure.
function findUser(int $id): ?User
{
$user = $this->db->find($id);
if ($user === null) return null;
return $user;
}
This is fine if "user not found" is the only error case. It is bad if there are multiple distinct errors (not found vs database connection failed vs unauthorized) because they all collapse into one null.
Exceptions used as control flow.
try {
$user = $this->findUser($id);
} catch (UserNotFoundException $e) {
return $this->createGuestUser();
}
When the catch is the normal path, the exception is not exceptional. The lookup should return a ?User and the caller should handle null explicitly. Exceptions for control flow are slow, surprising, and obscure the logic.
Result types in languages that do not support them well.
type Result<T, E> = { ok: true, value: T } | { ok: false, error: E };
function divide(a: number, b: number): Result<number, string> {
if (b === 0) return { ok: false, error: "Division by zero" };
return { ok: true, value: a / b };
}
This works but is verbose in TypeScript. Languages where Result types are first-class (Rust, F#, OCaml) get the ergonomic benefits. Languages where they are emulated (TypeScript, PHP) pay the cost without all the gains. Adopt them deliberately — usually for code where error handling correctness is more important than syntactic cleanliness.
API Design Implications
For libraries and APIs, the question is what callers expect.
A typical HTTP API returns errors in the response body, not as HTTP-level exceptions. A library wraps those in its own error types.
// API library
type ApiResult<T> = { data: T } | { error: ApiError };
async function getUser(id: string): Promise<ApiResult<User>> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) return { error: { code: res.status, message: await res.text() } };
return { data: await res.json() };
}
The caller decides whether to handle the error inline or propagate it. The library does not force a try/catch on every call site.
For HTTP responses themselves: error codes (4xx, 5xx) and error bodies are the standard. Throwing application-level exceptions over HTTP only works through your own client library.
A Decision Framework
When picking for a specific function or library:
- What does the language idiom expect? Match unless you have a reason not to.
- Is the error part of the contract? If yes, lean toward error returns or option types. If no (truly exceptional), lean toward exceptions.
- How many callers need to handle this specifically? If many, error returns force them to. If few, exceptions let the others ignore it.
- Is this in the hot path? Performance-critical code prefers error returns.
- Does the error need to carry information? Exceptions naturally carry stack traces; error values carry only what you put in them.
There is no universal answer. The honest pattern is to use both: error returns or option types for expected errors that callers care about, exceptions for truly unexpected conditions or programmer bugs.
What to Standardize Within a Codebase
What matters most is consistency within one codebase. Mixed conventions — half the codebase returns errors, half throws — produce confusion and bugs.
Pick a default and document it. Common defaults that work:
- Domain operations return result objects or
?Tfor expected errors - Infrastructure errors (database down, network timeout) throw exceptions
- Programmer errors (assertion violations, impossible states) throw exceptions
A small number of well-named exception classes plus result objects for expected outcomes covers most cases. Avoid having "ten different ways to fail" in one application.
Designing the error-handling story for an API or a library that will be used across teams? We help teams pick conventions that fit the language and survive contact with real consumers. scopeforged.com