Loading content...
Every API operation can fail. Networks drop packets. Resources become unavailable. Inputs violate constraints. Business rules prohibit actions. The question isn't whether your API will encounter errors—it's how you will communicate those errors to callers.
This decision—exceptions versus error codes—is one of the oldest and most consequential debates in software design. It affects code readability, error propagation, performance characteristics, and the cognitive load imposed on API consumers. Getting it wrong doesn't just create bugs; it creates entire categories of unhandled failures lurking in production systems.
By the end of this page, you will understand the fundamental mechanics of both exception-based and return-code-based error handling, their respective philosophies, when each approach shines (and fails), and how to make principled decisions based on context rather than dogma.
Before analyzing tradeoffs, we need precise definitions. Both approaches have evolved over decades, and understanding their mechanics is essential to evaluating them fairly.
Exception-Based Error Handling:
Exceptions are language-level constructs that allow a function to signal failure by throwing an object (the exception) which propagates up the call stack until caught by an enclosing handler. The normal return path is bypassed entirely.
Return-Code Error Handling:
Return codes embed error information in the function's return value. The caller receives data that may indicate success or failure, and must explicitly inspect the result to detect errors. The control flow remains linear—no stack unwinding occurs.
1234567891011121314151617181920212223
// Exception-based: errors interrupt normal flowpublic User findUserById(String userId) throws UserNotFoundException { User user = userRepository.lookup(userId); if (user == null) { // Throw interrupts execution and propagates up throw new UserNotFoundException("No user with ID: " + userId); } return user; // Only reached if no exception} // Caller codepublic void processOrder(String userId) { try { User user = findUserById(userId); // Happy path continues here createOrderForUser(user); sendConfirmation(user); } catch (UserNotFoundException e) { // Error handling separated from main logic log.error("Order failed: {}", e.getMessage()); notifySupport(e); }}Languages encode strong opinions about error handling. Java makes exceptions first-class. Go rejects exceptions entirely. Rust elevates return codes with type system support. Understanding these choices helps you work with—not against—your language's grain.
Exceptions emerged from a specific philosophical stance: errors are not normal returns. When a function fails, it shouldn't pretend to return normally. The call contract was violated, and the caller should be forced to acknowledge this violation.
This philosophy manifests in several design properties:
12345678910111213141516171819202122232425262728293031323334353637383940
// Exception propagation: intermediate layers don't need to handle errorspublic class OrderService { public void placeOrder(OrderRequest request) { // UserService.findUser() might throw UserNotFoundException // PaymentService.charge() might throw PaymentFailedException // InventoryService.reserve() might throw InsufficientStockException // None of these need try-catch here if OrderService // isn't the right place to handle them User user = userService.findUser(request.getUserId()); Payment payment = paymentService.charge(request.getPaymentInfo()); Reservation reservation = inventoryService.reserve(request.getItems()); // Only reached if ALL previous operations succeeded Order order = Order.create(user, payment, reservation); orderRepository.save(order); }} // Exceptions bubble up to wherever handling makes sense@RestControllerpublic class OrderController { @PostMapping("/orders") public ResponseEntity<?> createOrder(@RequestBody OrderRequest request) { try { orderService.placeOrder(request); return ResponseEntity.ok().build(); } catch (UserNotFoundException e) { return ResponseEntity.status(404).body(errorFor(e)); } catch (PaymentFailedException e) { return ResponseEntity.status(402).body(errorFor(e)); } catch (InsufficientStockException e) { return ResponseEntity.status(409).body(errorFor(e)); } // Unknown exceptions propagate to global handler }}The Invisible Try-Block:
A subtle but crucial aspect of exceptions: every function body is implicitly wrapped in an invisible try-block. Any throwable operation can exit the function at any line. This makes code appear simpler but requires assuming any line might not complete.
Consider the implications:
void processData() {
Resource r1 = acquire(); // Line completes
Resource r2 = acquire(); // Throws! r1 is acquired but what happens?
process(r1, r2); // Never reached
release(r1);
release(r2);
}
Without proper resource management (try-finally, try-with-resources, or RAII), exceptions create resource leaks. The solution is well-known—but the problem exists because exceptions allow any line to exit the function.
Return-code advocates argue from a fundamentally different premise: errors are just data. A function that can fail returns a union type—either a success value or an error value. There's nothing magical about failure; it's simply one possible outcome.
This philosophy produces distinctly different code characteristics:
1234567891011121314151617181920212223242526272829303132
// Return codes: every error point is explicitfunc PlaceOrder(ctx context.Context, request OrderRequest) error { user, err := userService.FindUser(ctx, request.UserID) if err != nil { return fmt.Errorf("finding user: %w", err) } payment, err := paymentService.Charge(ctx, request.PaymentInfo) if err != nil { return fmt.Errorf("charging payment: %w", err) } reservation, err := inventoryService.Reserve(ctx, request.Items) if err != nil { // We might need to refund the payment we charged if refundErr := paymentService.Refund(ctx, payment.ID); refundErr != nil { // Log refund failure but return original error log.Printf("CRITICAL: refund failed after inventory error: %v", refundErr) } return fmt.Errorf("reserving inventory: %w", err) } order := CreateOrder(user, payment, reservation) if err := orderRepo.Save(ctx, order); err != nil { // Compensating transactions are explicitly handled paymentService.Refund(ctx, payment.ID) inventoryService.Release(ctx, reservation.ID) return fmt.Errorf("saving order: %w", err) } return nil}Notice how the return-code examples make compensating transactions visible. When charging a payment before reserving inventory, you must consider what happens if inventory reservation fails. This explicit handling catches design issues that exception-based code might hide until production.
Neither approach is universally superior. Each trades certain benefits for others. Understanding these tradeoffs deeply enables principled decision-making.
| Characteristic | Exceptions | Return Codes |
|---|---|---|
| Error Visibility | Errors are hidden in control flow; any line might throw | Every error point is explicit in code |
| Propagation Effort | Automatic—exceptions bubble up without code | Manual—every layer must check and forward |
| Ignoring Errors | Impossible to ignore (unhandled exception terminates) | Easy to ignore (just don't check the return) |
| Code Volume | Less code for propagation-heavy cases | More code due to explicit checking |
| Control Flow | Non-local jumps through stack unwinding | Strictly local—returns exit only current function |
| Performance | Zero-cost on happy path; costly when thrown | Small constant cost on every fallible call |
| Debugging | Stack traces included automatically | Must compose error context manually |
| Refactoring Risk | Adding throws changes implicit contracts | Adding error returns changes explicit signatures |
The "Ignored Error" Problem:
Return-code critics correctly identify a critical weakness: forgetting to check errors is trivially easy and produces silent failures.
// This compiles and runs—but silently loses the error
user, _ := FindUser(userId) // Error ignored with blank identifier
ProcessUser(user) // Proceeds with nil user
// Even worse: implicit ignore
user, err := FindUser(userId)
if user != nil { // Should check err != nil first!
ProcessUser(user)
}
This is not theoretical. Studies of large codebases find significant percentages of error returns are unchecked. The fix is discipline and tooling (linters that warn on ignored errors), but the failure mode is real.
The "Hidden Throw" Problem:
Exception critics correctly identify the inverse weakness: every function call might exit your function, and you often don't know which ones.
void transfer(Account from, Account to, Money amount) {
from.debit(amount); // Might throw?
to.credit(amount); // Might throw—but from is already debited!
}
Without explicit documentation or checked exceptions, callers cannot know which operations throw. The fix is documentation discipline and careful contract design, but the failure mode is real.
Exceptions excel in specific contexts. Use them when their strengths align with your needs and their weaknesses are manageable.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// Deep call stack: exceptions avoid error threadingpublic class DocumentProcessor { public ProcessedDocument process(RawDocument doc) { // Exceptions from ANY of these nested operations // propagate directly to our caller without intermediate handling Document parsed = parser.parse(doc); // Might throw ParseException Document validated = validator.validate(parsed); // Might throw ValidationException Document enriched = enricher.enrich(validated); // Might throw EnrichmentException return transformer.transform(enriched); // Might throw TransformException }} // Constructor failure: no return value existspublic class DatabaseConnection { private final Connection underlyingConnection; public DatabaseConnection(String connectionString) throws ConnectionException { try { this.underlyingConnection = DriverManager.getConnection(connectionString); this.underlyingConnection.setAutoCommit(false); validateConnection(this.underlyingConnection); } catch (SQLException e) { throw new ConnectionException("Failed to establish connection", e); } // If we reach here, object is fully initialized and valid }} // Rich context with exception chainingpublic void processPayment(PaymentRequest request) throws PaymentException { try { gateway.submit(request); } catch (NetworkException e) { throw new PaymentException( "Payment gateway unreachable", e, // Original exception preserved PaymentErrorCode.GATEWAY_UNAVAILABLE, Map.of( "gatewayHost", gateway.getHost(), "timeout", gateway.getTimeout(), "requestId", request.getId() ) ); }}Return codes excel in their own contexts. Use them when explicit control flow, performance predictability, or local reasoning matters most.
1234567891011121314151617181920212223
// Expected failure: not finding a user is normal, not exceptionalfunc FindUserByEmail(email string) (*User, error) { user, err := db.QueryOne("SELECT * FROM users WHERE email = ?", email) if err == sql.ErrNoRows { return nil, nil // No user found—return nil, nil (not an error!) } if err != nil { return nil, fmt.Errorf("query failed: %w", err) } return user, nil} // Caller handles "not found" as a normal casefunc LoginOrRegister(email string) (*User, error) { user, err := FindUserByEmail(email) if err != nil { return nil, err // Actual error: propagate } if user == nil { return RegisterNewUser(email) // Normal flow: create new user } return user, nil}A useful rule: if the caller will almost always need to handle this condition explicitly, use return codes. If the condition represents a genuine surprise that most callers will propagate, use exceptions. 'User not found' is expected; 'database connection lost' is exceptional.
Many real-world systems use hybrid approaches, leveraging exceptions for some error categories and return codes for others. Modern languages increasingly provide mechanisms that blend both philosophies.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// Hybrid: Optional for "maybe", exceptions for errorspublic class UserRepository { // Not found is normal—return Optional public Optional<User> findByEmail(String email) { return Optional.ofNullable(doQuery(email)); } // Must exist—throw if not found public User getById(String id) throws UserNotFoundException { return findById(id) .orElseThrow(() -> new UserNotFoundException(id)); } // Database failure is exceptional—let it throw private User doQuery(String email) { // SQLException is wrapped in DataAccessException by Spring // and thrown as unchecked—infrastructure errors propagate return jdbcTemplate.queryForObject( "SELECT * FROM users WHERE email = ?", userMapper, email ); }} // Service layer uses both patterns appropriatelypublic class AuthService { public AuthResult authenticate(Credentials creds) { Optional<User> maybeUser = userRepo.findByEmail(creds.email()); if (maybeUser.isEmpty()) { return AuthResult.failure("User not found"); // Return code for expected case } User user = maybeUser.get(); if (!passwordHasher.verify(creds.password(), user.getPasswordHash())) { return AuthResult.failure("Invalid password"); // Return code for expected case } // Token generation failure is exceptional—let it throw String token = tokenService.generate(user); return AuthResult.success(token); }}When designing an API, your choice of error strategy affects every consumer. Here are principles that guide good decisions:
An API that inconsistently mixes strategies—sometimes throwing, sometimes returning null, sometimes returning error codes—is worse than either consistent approach. Callers never know what to expect and invariably get it wrong.
| Context | Recommended Approach | Reasoning |
|---|---|---|
| Internal library in exception-based language | Exceptions | Match ecosystem; leverage language support |
| Public API with diverse consumers | Consider both + clear documentation | Different consumers have different needs |
| Performance-critical inner loop | Return codes | Avoid exception overhead in hot paths |
| Operations that commonly 'fail' | Return codes or Optional | 'User not found' isn't exceptional |
| Operations that rarely fail | Exceptions | Failure is genuinely surprising |
| FFI/cross-language boundary | Return codes | Exceptions don't cross boundaries cleanly |
| Constructor/initialization | Exceptions or factory methods | Constructors can't return error codes |
We've explored the fundamental mechanics, philosophies, and tradeoffs of exception-based versus return-code-based error handling. Let's consolidate the key insights:
What's next:
In languages that support exceptions, you face a second decision: checked versus unchecked exceptions. This debate—especially fierce in the Java community—reveals deeper questions about contract enforcement, compile-time safety, and API evolution. The next page explores this nuanced topic in depth.
You now understand the fundamental choice between exceptions and return codes. This isn't a matter of preference—it's a design decision with concrete tradeoffs that should be made deliberately based on context, ecosystem, and requirements.