Java exception handling
Exceptions are how Java signals that something went wrong, separating error-handling code from the main logic. Handle them well and your programs fail safely and diagnosably; handle them poorly — swallowing errors, catching too broadly, leaking resources — and you get silent corruption that's miserable to debug. This guide covers the hierarchy, checked vs unchecked, the mechanics, and the practices that matter.
The exception hierarchy
Everything throwable descends from Throwable, which splits into:
Error— serious, usually unrecoverable JVM problems (OutOfMemoryError,StackOverflowError). Don't catch these.Exception— application-level conditions you may handle.RuntimeExceptionand subclasses are unchecked.- All other
Exceptionsubclasses are checked.
Throwable
├── Error (unchecked — don't catch)
└── Exception
├── RuntimeException (unchecked)
└── IOException, SQLException... (checked)
So "checked vs unchecked" is simply about whether the class sits under
RuntimeException/Error.
Checked vs unchecked
- Checked exceptions are verified by the compiler: a method must either
catchthem or declarethrows. They represent recoverable, expected conditions (IOException,SQLException). - Unchecked exceptions aren't compiler-enforced — usually programming bugs
(
NullPointerException,IllegalArgumentException,ArrayIndexOutOfBoundsException).
void read() throws IOException { // checked -> must declare or catch
Files.readString(Path.of("x"));
}
The guideline: checked for conditions a caller can reasonably recover from,
unchecked for bugs and contract violations. Modern frameworks (Spring) lean heavily
toward unchecked to avoid throws clutter.
try, catch, finally
try wraps risky code, catch handles specific types, and finally runs always —
even after a return — for cleanup. Order catch blocks specific-before-general, or
the compiler complains.
try {
read();
} catch (FileNotFoundException e) { // specific first
recoverMissing();
} catch (IOException e) { // broader after
log(e);
} finally {
cleanup(); // always runs
}
finally runs in virtually all cases — only System.exit(), a JVM crash, or an infinite
loop skip it. Never put a return/throw in finally: it overrides the try's result
and silently swallows pending exceptions — a notorious bug.
try-with-resources
For anything that needs closing (streams, connections, locks), declare it in a try-with-resources; it auto-closes in reverse order when the block exits, normally or via exception.
try (var br = Files.newBufferedReader(path);
var conn = dataSource.getConnection()) {
return br.readLine();
} // br and conn closed automatically
The resource must implement AutoCloseable. This replaces error-prone manual finally
cleanup and correctly handles suppressed exceptions: if both the body and close()
throw, the body's exception is primary and the close exception is attached via
getSuppressed() — nothing is lost. Multi-catch (catch (IOException | SQLException e)) collapses identical handlers.
throw vs throws, and custom exceptions
throw is a statement that raises an exception now; throws is a method clause declaring
which checked exceptions may propagate. Create custom exceptions by extending Exception
(checked) or RuntimeException (unchecked), passing the message and cause to super.
public class InsufficientFundsException extends RuntimeException {
public InsufficientFundsException(int shortfall) {
super("Short by " + shortfall + " cents");
}
}
Decide checked vs unchecked by whether the caller can recover. Custom exceptions let you
carry domain data and let callers catch precisely.
Exception chaining and translation
When you catch a low-level exception and rethrow a higher-level one, preserve the
original as the cause so the full diagnostic trail survives. Exception translation
converts low-level exceptions into ones appropriate to the current layer (a service
shouldn't leak SQLException).
try {
jdbc.query();
} catch (SQLException e) {
throw new DataAccessException("load failed", e); // e becomes the cause
}
The stack trace then shows Caused by:. This is exactly what Spring's
DataAccessException hierarchy does.
NullPointerException and Optional
An NPE happens when you dereference null — call a method, access a field, index an
array, or unbox a null wrapper. Java 14+ gives helpful messages naming the exact null
expression. Prevent NPEs with Objects.requireNonNull, constants-on-the-left
("x".equals(s)), and Optional for maybe-absent return values:
Optional<User> user = repo.findById(id);
String name = user.map(User::name).orElse("unknown");
Use Optional as a return type — not for fields, parameters, or collections (return an
empty collection instead).
Best practices
- Catch specific exceptions, not bare
Exception/Throwable(which hides bugs and catchesErrors you can't handle). - Never swallow — an empty
catchhides failures and loses diagnostics. Handle, log, or rethrow. - Throw early, catch late — validate inputs at the boundary; handle where you have context to recover.
- Don't use exceptions for control flow — building the stack trace is expensive.
- Preserve the cause when wrapping, and log once at the boundary, not at every layer.
// swallowed — silent failure
try { risky(); } catch (Exception e) {}
// at minimum, log with the stack trace
try { risky(); } catch (Exception e) { log.error("risky failed", e); throw e; }
Recap
Java exceptions descend from Throwable (Error vs Exception, the latter split
into checked and unchecked). Use try/catch/finally with specific-first ordering,
try-with-resources for automatic cleanup and suppressed-exception handling, and
chaining/translation to preserve causes across layers. Reserve checked exceptions for
recoverable conditions, lean on Optional to dodge NPEs, and follow the practices — catch
narrowly, never swallow, throw early/catch late, log once. Done right, exception handling
makes failures safe and debuggable instead of mysterious.