Java Exception Handling Best Practices
Clear rules for using checked and unchecked exceptions in Java, with patterns for wrapping, logging, and propagating errors in real production code.
What you'll learn
- ✓The difference between checked, unchecked, and Error
- ✓When to wrap versus rethrow
- ✓How try-with-resources prevents leaks
- ✓Patterns for logging exceptions without losing context
- ✓Designing your own exception hierarchy
Prerequisites
- •Basic familiarity with Java syntax
What and Why
Exceptions are Java’s primary mechanism for signaling that a method could not fulfill its contract. Used well, they make failure paths explicit and let callers handle problems at the right layer. Used badly, they create noisy logs, hide bugs, and turn small failures into outages.
Good exception code is not about catching everything. It is about deciding what each layer can meaningfully do when something goes wrong.
Mental Model
Java exceptions form a hierarchy. Throwable splits into Error (JVM-level problems you should not catch) and Exception. Under Exception, RuntimeException and its subclasses are unchecked; everything else is checked and must be declared or handled.
Throwable
|-- Error (OutOfMemoryError, StackOverflowError)
|-- Exception (checked: IOException, SQLException)
|-- RuntimeException (unchecked: NullPointerException,
IllegalArgumentException, ...) The rough rule: throw checked exceptions for recoverable conditions a caller can reasonably handle. Throw unchecked exceptions for programmer errors and broken invariants.
Hands-on Example
A common case is reading a file and parsing it.
import java.io.*;
import java.nio.file.*;
import java.util.List;
public class Config {
public static List<String> loadLines(Path path) throws ConfigException {
try {
return Files.readAllLines(path);
} catch (NoSuchFileException e) {
throw new ConfigException("Config not found: " + path, e);
} catch (IOException e) {
throw new ConfigException("Failed reading: " + path, e);
}
}
}
class ConfigException extends Exception {
public ConfigException(String message, Throwable cause) {
super(message, cause);
}
}
Notice three things. First, the low-level IOException is translated into a domain-meaningful ConfigException. Second, the original cause is preserved with the two-argument constructor. Third, the catch blocks are specific, so each failure mode could grow its own response.
Try-with-resources handles cleanup deterministically:
try (BufferedReader r = Files.newBufferedReader(path)) {
return r.lines().toList();
}
If an exception escapes the body and close also throws, the secondary failure is attached as a suppressed exception, not silently swallowed.
Common Pitfalls
- Swallowing exceptions: an empty catch block is almost always a bug. At minimum, log the exception with
logger.error("context", e)so the stack trace survives. - Losing the cause: writing
throw new MyException(e.getMessage())discards the stack. Always passeas the cause. - Catching
ExceptionorThrowable: too broad. You also catchRuntimeExceptions you would rather see crash in tests. Catch the narrowest type that matches your recovery plan. - Using exceptions for control flow: throwing and catching is expensive and obscures intent. For predictable conditions (missing map key, end of input), return
Optionalor a sentinel. - Wrapping then logging then rethrowing: pick one. Either log at the boundary where you handle the error, or rethrow and let a higher layer log it. Logging the same exception three times pollutes operational dashboards.
Practical Tips
Design a small exception hierarchy per module. Public methods throw a single base type (often unchecked), with subclasses for cases callers might handle differently.
For input validation, prefer Objects.requireNonNull, Preconditions-style guards, or IllegalArgumentException with a clear message. Fail fast at the boundary.
Use try-with-resources for anything implementing AutoCloseable: file streams, JDBC connections, HTTP clients with explicit shutdown. The pattern is shorter and safer than finally.
When integrating with frameworks (Spring, Jakarta EE), prefer unchecked exceptions for business errors. They flow through proxies cleanly and avoid the noise of declared throws clauses everywhere.
For logging, include identifiers: order ID, request ID, file path. A stack trace is far more useful when you know which input produced it. Tools like SLF4J’s MDC make this systematic.
Wrap-up
Treat exceptions as a design decision, not a reflex. Choose checked or unchecked based on what callers can reasonably do. Wrap to preserve domain meaning, but always keep the cause chain intact. Close resources with try-with-resources. With these habits, error paths become as understandable as the happy path, and production incidents become much easier to diagnose.
Related articles
- Java Java Optional Best Practices
Use Java's Optional type correctly: when to return it, when to avoid it, and how to chain operations safely without ever calling get().
- Java Java Exceptions: Checked, Unchecked, and Best Practices
Throw, catch, and chain exceptions without leaking abstractions. Checked vs unchecked, try with resources, custom exceptions, and rules that scale.
- Python Python Error Handling with try and except
A practical guide to error handling in Python — catching specific exceptions, the else and finally clauses, raising and re-raising, custom exception types, and habits that lead to robust code.
- Java Java Collections Framework Cheatsheet
A pragmatic tour of Java's collection interfaces and implementations, with guidance on choosing between List, Set, Map, and Queue variants in real applications.