Exceptions: Checked vs Unchecked, and Clean Error Handling
Most programs spend most of their life running smoothly. The quality of your code shows up in the other 10% — when files are missing, networks stall, and input is nonsense. Java's exception system is designed to make that 10% explicit instead of accidental. This post gives you the one decision rule that settles almost every "should I throw or catch this?" question, then shows you the modern mechanics.
The hierarchy: what can actually be thrown
Everything throwable in Java sits under one root, Throwable. The tree splits into two families with very different philosophies:
- Error — things your program can't plausibly recover from (
OutOfMemoryError,StackOverflowError). Rule: let these crash. Catching them is almost always wrong. - Exception, checked side — conditions a caller can anticipate and recover from (
IOException,SQLException). The compiler forces you to handle or declare them. - Exception → RuntimeException, unchecked side — programming bugs and invalid usage (
NullPointerException,IllegalArgumentException,IndexOutOfBoundsException). The compiler doesn't force you to handle them, because the correct response is usually "fix the code", not "catch it".
The decision rule: checked or unchecked?
Forget memorized lists. Ask one question:
Can the caller plausibly anticipate this and recover?
- Yes → checked exception. Example:
IOException— the file genuinely might not exist, and the caller might retry or fall back.- No — it's a bug or invalid usage → unchecked (
RuntimeException). Example:IllegalArgumentException— the caller passed a negative age; the fix is in the calling code, not in a catch block.
What breaks if you get it wrong? Make a bug unchecked-worthy situation a checked exception, and you force every caller to handle a condition they can't recover from — boilerplate everywhere. Make a recoverable condition unchecked, and callers never learn they should handle it until production blows up. The rule keeps both failure modes honest.
// Checked: the caller can decide what to do.
void loadConfig(String path) throws IOException {
// Files.readString may fail — that's a real, foreseeable event.
}
// Unchecked: a bug in the caller. Fix the caller, don't catch it.
void setAge(int age) {
if (age < 0) {
throw new IllegalArgumentException("age must be >= 0, got " + age);
}
}
try / catch / finally
The mechanics: try guards code that may fail, catch handles a specific failure, finally runs no matter what (cleanup). Catch from most specific to most general — the first matching catch wins.
import java.io.IOException;
import java.nio.file.*;
public class CatchOrder {
public static void main(String[] args) {
try {
String text = Files.readString(Path.of("config.txt"));
System.out.println("Loaded: " + text.length() + " chars");
} catch (NoSuchFileException e) {
System.out.println("Config missing, using defaults");
} catch (IOException e) {
System.out.println("Could not read config: " + e.getMessage());
}
}
}
Output when the file is missing:
Config missing, using defaults
If the catches were reversed, NoSuchFileException (a subclass of IOException) would never be reached — the compiler rejects unreachable catch blocks, which is one place Java actually saves you from your own ordering mistakes.
Multi-catch: one handler, several exceptions
When the recovery is identical, don't write three catch blocks:
try {
int port = Integer.parseInt(args[0]);
connect(port);
} catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
System.out.println("Usage: app <port-number>");
}
Constraint: the exceptions in one multi-catch can't be in a subclass relationship with each other (e.g. IOException | FileNotFoundException won't compile — the general one already covers the specific).
try-with-resources: the default for anything closeable
Any object implementing AutoCloseable (streams, readers, database connections, scanners) should be opened in try-with-resources. Compare the old ceremony with the modern one-liner:
// The old finally-close ceremony — verbose, and easy to get subtly wrong.
BufferedReader reader = null;
try {
reader = Files.newBufferedReader(Path.of("data.txt"));
System.out.println(reader.readLine());
} catch (IOException e) {
System.out.println("read failed: " + e.getMessage());
} finally {
if (reader != null) {
try { reader.close(); } catch (IOException ignored) { }
}
}
// Modern default: the resource closes automatically, in reverse order,
// and any exception from close() is attached as a *suppressed* exception
// on the original — nothing is silently lost.
try (BufferedReader reader = Files.newBufferedReader(Path.of("data.txt"))) {
System.out.println(reader.readLine());
} catch (IOException e) {
System.out.println("read failed: " + e.getMessage());
for (Throwable s : e.getSuppressed()) {
System.out.println("suppressed: " + s);
}
}
This is the single biggest error-handling upgrade in modern Java, and it's been the default idiom since Java 7. If you're writing finally { close(); } by hand in new code, stop — that's what this is for. (We'll lean on it heavily in post 18, on I/O.)
Custom exceptions: when they earn their keep
Don't create a custom exception for decoration. Create one when the caller needs to distinguish your failure from everything else — i.e. when catching IllegalArgumentException wouldn't be precise enough.
// Earns its keep: a booking service where "seat taken" needs
// different handling (offer alternatives) than "bad input" (reject).
public class SeatUnavailableException extends RuntimeException {
private final String seatId;
public SeatUnavailableException(String seatId) {
super("Seat " + seatId + " is already booked");
this.seatId = seatId;
}
public SeatUnavailableException(String seatId, Throwable cause) {
super("Seat " + seatId + " is already booked", cause);
this.seatId = seatId;
}
public String getSeatId() { return seatId; }
}
Two rules packed in there:
- Extend
RuntimeExceptionunless the caller can genuinely recover (apply the decision rule again). Most custom exceptions should be unchecked. - Always offer a constructor taking a cause — that's exception chaining.
Exception chaining: never lose the original cause
When you catch a low-level exception and translate it into your domain exception, pass the original as the cause. The stack trace of the original is preserved and printed after "Caused by:".
try {
reserveInDatabase(seatId);
} catch (SQLException e) {
// The caller shouldn't know about SQL. But the cause must survive
// for whoever debugs this at 2am.
throw new SeatUnavailableException(seatId, e);
}
Without chaining, the SQLException — the one fact that explains why the seat couldn't be booked — vanishes. That's how "works on my machine" mysteries are born.
Anti-patterns: the hall of shame
1. Catching Exception broadly
try {
processPayment(order);
} catch (Exception e) { // DON'T
System.out.println("oops");
}
This catches NullPointerException and IllegalArgumentException — your bugs — alongside the payment failure, then treats them all as "oops". Bugs get hidden instead of fixed. Catch the specific exceptions you can actually handle; let the rest propagate to someone who can see them.
2. Empty catch blocks
try {
config.load();
} catch (IOException e) { } // DON'T — the failure now never happened
An empty catch is a lie: the program continues as if nothing failed, and the config is silently missing. If you truly can't do anything, at minimum log it. Better: don't catch what you can't handle.
3. Exceptions for control flow
// DON'T — exceptions are not a loop-exit mechanism.
try {
while (true) { queue.remove(); }
} catch (NoSuchElementException e) { /* done */ }
Exceptions are expensive (stack traces aren't free) and this reads as a bug to every future reader. Use the API's own signals — queue.poll() returning null, an isEmpty() check, a proper loop condition.
4. throws Exception on everything
public void save(Order o) throws Exception { ... } // DON'T
This tells the caller exactly nothing: which exceptions? recoverable how? It forces a broad catch at every call site, which is anti-pattern #1 all over again. Declare the specific checked exceptions you can actually throw.
5. Log-and-swallow
try {
syncToWarehouse(order);
} catch (IOException e) {
logger.error("sync failed", e); // logged… and then forgotten
}
Logging is not handling. If the sync matters, the caller needs to know it failed — rethrow, wrap in a domain exception, or return a result the caller must inspect. Log-and-swallow converts a recoverable failure into silent data loss.
Your handling strategy: the checklist
- Fail fast on bugs.
NullPointerException,IllegalArgumentException, bad state — throw unchecked, don't catch. The stack trace is the report; fix the code. - Recover on expected conditions. File missing, network timeout, bad user input — catch the specific checked exception (or validate input up front) and do something real: retry, fall back, ask again.
- Use try-with-resources for everything closeable. No hand-rolled finally-close ceremony in new code.
- Chain your causes. When translating exceptions across layers, keep the original as the cause.
- Never silently swallow. Empty catches, broad catches, and log-and-swallow all hide failures. If you catch it, handle it — otherwise let it propagate.
- Be specific in signatures. Declare exactly which checked exceptions you throw; never
throws Exception.
Next up: we put this into practice where exceptions matter most — reading and writing files and streams in post 18, on I/O. Everything after that builds on code that fails cleanly instead of failing mysteriously.
Continue: Java Learning Roadmap 2026
Comments
Post a Comment