Error Handling Best Practices: Exceptions vs Return Values
Practice3 minInnovialabs editorial team
In short
Exceptions are convenient when an error must travel far up the stack, while error values and Result make every failure explicit in the signature. More important than the approach: never lose errors, add context and handle them where a decision can be made.
The short answer
There is no universally better approach, and the language usually decides for you. Exceptions (Python, Java, C#, JavaScript) interrupt execution and bubble up the stack until something catches them. Error values (Go) and the Result type (Rust) are returned like any other result, and the caller has to deal with them.
Good error handling rests on three rules: never swallow failures, add context, and handle the error where a decision can be made.
What it looks like in different languages
Python, exceptions:
def load_config(path):
try:
with open(path) as f:
return json.load(f)
except FileNotFoundError as e:
raise ConfigError(f"config not found: {path}") from e
Go, error as a value:
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("load config %s: %w", path, err)
}
// ...
}
Rust, Result and the ? operator:
fn load_config(path: &str) -> Result<Config, ConfigError> {
let data = std::fs::read_to_string(path)?;
let config = parse(&data)?;
Ok(config)
}
Comparing the approaches
| Exceptions | Error values (Go) | Result (Rust) | |
|---|---|---|---|
| Visible in the signature | partly (checked exceptions in Java) | yes | yes |
| Easy to forget handling | very | err can be ignored | compiler warns |
| Amount of code | less | more if err != nil | compact with ? |
| Passing far up | automatic | manual | via ? |
Exceptions fit truly exceptional situations. Error values work well where failure is a normal scenario: the file is missing, the user entered invalid data, a service is down.
Rules that work in any language
- Do not swallow errors. An empty
except: passor_ = errhides the problem, and it surfaces later somewhere confusing. - Catch specific types.
except Exceptionalso catches your own bugs. CatchFileNotFoundErrororTimeoutError, the things you can actually handle. - Add context. "file not found" is useless without the file name and the operation. Keep the original cause:
raise ... from ein Python,%win Go,causein Java and JavaScript. - Create custom error types for business cases:
InsufficientFundsError,OrderNotFound. Callers can then tell them apart from technical failures. - Handle at the right level. A low-level function passes the error up with context. The decision to retry, show a message or return HTTP 404 belongs to the layer that knows the scenario.
- Log once. If every layer logs the error and rethrows it, you get five copies of one failure in the logs.
- Separate audiences. The user gets a clear message without implementation details. The log gets the stack trace, parameters and request id.
Common mistakes
- Returning
nullor-1instead of an error, so the caller never learns the reason. - Using exceptions for normal control flow, such as breaking out of a loop.
- Showing the user a stack trace or raw SQL error text, which is also a security risk.
- Retrying forever with no delay and no attempt limit.